diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index b8f04ff351..fa9bf10336 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -102,6 +102,27 @@ _Appears in:_
| `tokenExchange` _[auth.types.TokenExchangeConfig](#authtypestokenexchangeconfig)_ | TokenExchange contains configuration for token exchange auth strategy.
Used when Type = "token_exchange". | | |
| `upstreamInject` _[auth.types.UpstreamInjectConfig](#authtypesupstreaminjectconfig)_ | UpstreamInject contains configuration for upstream inject auth strategy.
Used when Type = "upstream_inject". | | |
| `awsSts` _[auth.types.AwsStsConfig](#authtypesawsstsconfig)_ | AwsSts contains configuration for AWS STS auth strategy.
Used when Type = "aws_sts". | | |
+| `claimInjection` _[auth.types.ClaimInjectionConfig](#authtypesclaiminjectionconfig)_ | ClaimInjection contains configuration for the claim injection auth strategy.
Used when Type = "claim_injection". | | |
+
+
+#### auth.types.ClaimInjectionConfig
+
+
+
+ClaimInjectionConfig configures the claim injection auth strategy.
+This strategy reads the authenticated user's identity from the request context
+and injects selected claims as X-User-* HTTP headers into outgoing backend requests.
+Backend MCP servers can read these headers to identify the caller without performing
+their own OAuth token introspection or /introspect calls.
+
+
+
+_Appears in:_
+- [auth.types.BackendAuthStrategy](#authtypesbackendauthstrategy)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `claims` _string array_ | Claims lists which identity claims to inject as X-User-* headers.
Supported values: "sub" (→ X-User-Sub), "email" (→ X-User-Email), "name" (→ X-User-Name).
Defaults to ["sub"] when empty. Including "email" is opt-in to minimise PII forwarded to backends. | | |
#### auth.types.HeaderInjectionConfig
diff --git a/pkg/transport/http.go b/pkg/transport/http.go
index 7f9bb174b9..8d60fcffac 100644
--- a/pkg/transport/http.go
+++ b/pkg/transport/http.go
@@ -330,6 +330,14 @@ func (t *HTTPTransport) Start(ctx context.Context) error {
})
}
+ // Always inject user identity claims (sub, email, name) as headers so backend MCP servers
+ // can identify the authenticated user without needing to call /introspect themselves.
+ // When no identity is in context (anonymous request), this middleware is a no-op.
+ middlewares = append(middlewares, types.NamedMiddleware{
+ Name: "claim-injection",
+ Function: middleware.NewClaimInjectionMiddleware(),
+ })
+
// Determine whether to enable health checks based on workload type
enableHealthCheck := shouldEnableHealthCheck(isRemote)
diff --git a/pkg/transport/middleware/claim_injection.go b/pkg/transport/middleware/claim_injection.go
new file mode 100644
index 0000000000..798f04b653
--- /dev/null
+++ b/pkg/transport/middleware/claim_injection.go
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/stacklok/toolhive/pkg/auth"
+)
+
+const (
+ // HeaderUserSub is the HTTP header name for forwarding the authenticated user's subject claim.
+ // Backend MCP servers can read this header to identify the user without calling /introspect.
+ HeaderUserSub = "X-User-Sub"
+ // HeaderUserEmail is the HTTP header name for forwarding the authenticated user's email claim.
+ HeaderUserEmail = "X-User-Email"
+ // HeaderUserName is the HTTP header name for forwarding the authenticated user's name claim.
+ HeaderUserName = "X-User-Name"
+)
+
+// NewClaimInjectionMiddleware returns a middleware that extracts user identity from the
+// request context (populated by auth middleware) and injects it as HTTP headers into the
+// forwarded request. This allows backend MCP servers to receive user identity without
+// needing to implement their own OAuth token validation or /introspect calls.
+//
+// Headers injected (when identity is present):
+// - X-User-Sub: the 'sub' claim (Google/OIDC user ID, always present)
+// - X-User-Email: the 'email' claim (if available in token)
+// - X-User-Name: the 'name' claim (if available in token)
+//
+// This middleware is safe to add unconditionally: if no identity is present in context
+// (e.g., anonymous request), no headers are injected.
+func NewClaimInjectionMiddleware() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ identity, ok := auth.IdentityFromContext(r.Context())
+ if ok && identity != nil {
+ // Clone request to avoid modifying the original
+ r = r.Clone(r.Context())
+ if identity.Subject != "" {
+ r.Header.Set(HeaderUserSub, identity.Subject)
+ }
+ if identity.Email != "" {
+ r.Header.Set(HeaderUserEmail, identity.Email)
+ }
+ if identity.Name != "" {
+ r.Header.Set(HeaderUserName, identity.Name)
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/pkg/vmcp/auth/factory/outgoing.go b/pkg/vmcp/auth/factory/outgoing.go
index 9510bedbae..af60b690e6 100644
--- a/pkg/vmcp/auth/factory/outgoing.go
+++ b/pkg/vmcp/auth/factory/outgoing.go
@@ -81,6 +81,12 @@ func NewOutgoingAuthRegistry(
); err != nil {
return nil, err
}
+ if err := registry.RegisterStrategy(
+ authtypes.StrategyTypeClaimInjection,
+ strategies.NewClaimInjectionStrategy(),
+ ); err != nil {
+ return nil, err
+ }
return registry, nil
}
diff --git a/pkg/vmcp/auth/strategies/claim_injection.go b/pkg/vmcp/auth/strategies/claim_injection.go
new file mode 100644
index 0000000000..9db7ef3c26
--- /dev/null
+++ b/pkg/vmcp/auth/strategies/claim_injection.go
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package strategies
+
+import (
+ "context"
+ "net/http"
+ "slices"
+
+ "github.com/stacklok/toolhive/pkg/auth"
+ authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
+ healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context"
+)
+
+// ClaimInjectionStrategy injects authenticated user identity claims as HTTP headers
+// into outgoing backend requests.
+//
+// This enables backend MCP servers to identify the caller (e.g. read X-User-Sub)
+// without needing their own OAuth token validation or /introspect calls. The gateway
+// is the sole trust boundary; backends trust the headers because they are unreachable
+// by anything other than the gateway (enforced via Cloud Run IAM or equivalent).
+//
+// Which claims to forward is opt-in via ClaimInjectionConfig.Claims to minimise PII
+// exposure. The default (empty Claims list) injects only X-User-Sub.
+type ClaimInjectionStrategy struct{}
+
+// NewClaimInjectionStrategy creates a new ClaimInjectionStrategy instance.
+func NewClaimInjectionStrategy() *ClaimInjectionStrategy {
+ return &ClaimInjectionStrategy{}
+}
+
+// Name returns the strategy identifier.
+func (*ClaimInjectionStrategy) Name() string {
+ return authtypes.StrategyTypeClaimInjection
+}
+
+// Authenticate reads the per-request identity from the context and injects the
+// configured claims as X-User-* headers on the outgoing request.
+//
+// The method is a no-op (returns nil without modifying the request) when:
+// - the request is a health check (no real user identity available)
+// - no identity is present in the request context
+// - the identity subject is empty or "anonymous" (unauthenticated mode)
+func (*ClaimInjectionStrategy) Authenticate(
+ ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy,
+) error {
+ // Health-check probes carry no user identity; skip silently.
+ if healthcontext.IsHealthCheck(ctx) {
+ return nil
+ }
+
+ identity, ok := auth.IdentityFromContext(ctx)
+ if !ok || identity == nil {
+ return nil
+ }
+
+ // Skip anonymous sessions (vmcp unauthenticated mode).
+ // In anonymous mode the subject is "anonymous" and email is "anonymous@localhost".
+ if identity.Subject == "" || identity.Subject == "anonymous" {
+ return nil
+ }
+
+ // Determine which claims to inject. Default to ["sub"] when not configured.
+ claims := []string{"sub"}
+ if strategy != nil && strategy.ClaimInjection != nil && len(strategy.ClaimInjection.Claims) > 0 {
+ claims = strategy.ClaimInjection.Claims
+ }
+
+ if slices.Contains(claims, "sub") && identity.Subject != "" {
+ req.Header.Set("X-User-Sub", identity.Subject)
+ }
+ if slices.Contains(claims, "email") && identity.Email != "" {
+ req.Header.Set("X-User-Email", identity.Email)
+ }
+ if slices.Contains(claims, "name") && identity.Name != "" {
+ req.Header.Set("X-User-Name", identity.Name)
+ }
+
+ return nil
+}
+
+// Validate checks strategy configuration. ClaimInjectionConfig is optional
+// (defaults to sub-only injection), so an absent config is valid.
+func (*ClaimInjectionStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error {
+ return nil
+}
diff --git a/pkg/vmcp/auth/strategies/claim_injection_test.go b/pkg/vmcp/auth/strategies/claim_injection_test.go
new file mode 100644
index 0000000000..b4424314f8
--- /dev/null
+++ b/pkg/vmcp/auth/strategies/claim_injection_test.go
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package strategies_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stacklok/toolhive/pkg/auth"
+ "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies"
+ authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
+ healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context"
+)
+
+func newReqWithIdentity(t *testing.T, identity *auth.Identity) *http.Request {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody)
+ if identity != nil {
+ req = req.WithContext(auth.WithIdentity(req.Context(), identity))
+ }
+ return req
+}
+
+func strategy(claims ...string) *authtypes.BackendAuthStrategy {
+ if len(claims) == 0 {
+ return &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeClaimInjection}
+ }
+ return &authtypes.BackendAuthStrategy{
+ Type: authtypes.StrategyTypeClaimInjection,
+ ClaimInjection: &authtypes.ClaimInjectionConfig{Claims: claims},
+ }
+}
+
+func TestClaimInjectionStrategy_Name(t *testing.T) {
+ s := strategies.NewClaimInjectionStrategy()
+ assert.Equal(t, "claim_injection", s.Name())
+}
+
+func TestClaimInjectionStrategy_DefaultSubOnly(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
+ Subject: "108352771234567890",
+ Email: "user@example.com",
+ Name: "Test User",
+ }}
+ req := newReqWithIdentity(t, identity)
+
+ err := s.Authenticate(req.Context(), req, strategy())
+
+ require.NoError(t, err)
+ assert.Equal(t, "108352771234567890", req.Header.Get("X-User-Sub"), "sub injected by default")
+ assert.Empty(t, req.Header.Get("X-User-Email"), "email NOT injected by default (opt-in)")
+ assert.Empty(t, req.Header.Get("X-User-Name"), "name NOT injected by default (opt-in)")
+}
+
+func TestClaimInjectionStrategy_ExplicitClaims(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
+ Subject: "108352771234567890",
+ Email: "user@example.com",
+ Name: "Test User",
+ }}
+ req := newReqWithIdentity(t, identity)
+
+ err := s.Authenticate(req.Context(), req, strategy("sub", "email", "name"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "108352771234567890", req.Header.Get("X-User-Sub"))
+ assert.Equal(t, "user@example.com", req.Header.Get("X-User-Email"))
+ assert.Equal(t, "Test User", req.Header.Get("X-User-Name"))
+}
+
+func TestClaimInjectionStrategy_SkipsAnonymous(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ anonymous := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
+ Subject: "anonymous",
+ Email: "anonymous@localhost",
+ }}
+ req := newReqWithIdentity(t, anonymous)
+
+ err := s.Authenticate(req.Context(), req, strategy("sub", "email"))
+
+ require.NoError(t, err)
+ assert.Empty(t, req.Header.Get("X-User-Sub"), "anonymous identity must not inject headers")
+ assert.Empty(t, req.Header.Get("X-User-Email"))
+}
+
+func TestClaimInjectionStrategy_SkipsNoIdentity(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody)
+
+ err := s.Authenticate(req.Context(), req, strategy("sub", "email"))
+
+ require.NoError(t, err)
+ assert.Empty(t, req.Header.Get("X-User-Sub"))
+}
+
+func TestClaimInjectionStrategy_SkipsHealthCheck(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "sub-123"}}
+ req := newReqWithIdentity(t, identity)
+ req = req.WithContext(healthcontext.WithHealthCheckMarker(req.Context()))
+
+ err := s.Authenticate(req.Context(), req, strategy("sub"))
+
+ require.NoError(t, err)
+ assert.Empty(t, req.Header.Get("X-User-Sub"), "health checks must not inject headers")
+}
+
+func TestClaimInjectionStrategy_EmptyFieldsNotInjected(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "sub-only"}}
+ req := newReqWithIdentity(t, identity)
+
+ err := s.Authenticate(req.Context(), req, strategy("sub", "email", "name"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "sub-only", req.Header.Get("X-User-Sub"))
+ assert.Empty(t, req.Header.Get("X-User-Email"), "empty email must not produce header")
+ assert.Empty(t, req.Header.Get("X-User-Name"), "empty name must not produce header")
+}
+
+func TestClaimInjectionStrategy_Validate(t *testing.T) {
+ s := strategies.NewClaimInjectionStrategy()
+ assert.NoError(t, s.Validate(nil), "nil config is valid (defaults to sub-only)")
+ assert.NoError(t, s.Validate(&authtypes.BackendAuthStrategy{}))
+}
+
+func TestClaimInjectionStrategy_NilContext(t *testing.T) {
+ t.Parallel()
+ s := strategies.NewClaimInjectionStrategy()
+ req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody)
+ // No identity in context — should return nil, not panic.
+ err := s.Authenticate(context.Background(), req, strategy("sub"))
+ assert.NoError(t, err)
+}
diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go
index a178383e59..373613a7eb 100644
--- a/pkg/vmcp/auth/types/types.go
+++ b/pkg/vmcp/auth/types/types.go
@@ -48,6 +48,13 @@ const (
// The default upstream implementation returns ErrEnterpriseRequired from
// every method; an out-of-tree build registers a real converter.
StrategyTypeOBO = "obo"
+
+ // StrategyTypeClaimInjection identifies the claim injection strategy.
+ // This strategy reads the authenticated user's identity from the request context
+ // and injects selected claims as X-User-* HTTP headers into backend requests.
+ // Backend MCP servers can read these headers to identify the caller without
+ // performing their own OAuth token introspection.
+ StrategyTypeClaimInjection = "claim_injection"
)
// BackendAuthStrategy defines how to authenticate to a specific backend.
@@ -57,7 +64,7 @@ const (
// +kubebuilder:object:generate=true
// +gendoc
type BackendAuthStrategy struct {
- // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo"
+ // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo", "claim_injection"
Type string `json:"type" yaml:"type"`
// HeaderInjection contains configuration for header injection auth strategy.
@@ -75,6 +82,10 @@ type BackendAuthStrategy struct {
// AwsSts contains configuration for AWS STS auth strategy.
// Used when Type = "aws_sts".
AwsSts *AwsStsConfig `json:"awsSts,omitempty" yaml:"awsSts,omitempty"`
+
+ // ClaimInjection contains configuration for the claim injection auth strategy.
+ // Used when Type = "claim_injection".
+ ClaimInjection *ClaimInjectionConfig `json:"claimInjection,omitempty" yaml:"claimInjection,omitempty"`
}
// HeaderInjectionConfig configures the header injection auth strategy.
@@ -145,6 +156,24 @@ type UpstreamInjectConfig struct {
ProviderName string `json:"providerName" yaml:"providerName"`
}
+// ClaimInjectionConfig configures the claim injection auth strategy.
+// This strategy reads the authenticated user's identity from the request context
+// and injects selected claims as X-User-* HTTP headers into outgoing backend requests.
+// Backend MCP servers can read these headers to identify the caller without performing
+// their own OAuth token introspection or /introspect calls.
+// +kubebuilder:object:generate=true
+// +gendoc
+type ClaimInjectionConfig struct {
+ // Claims lists which identity claims to inject as X-User-* headers.
+ // Supported values: "sub", "email", "name"
+ // - "sub" → X-User-Sub (OIDC subject; immutable across renames)
+ // - "email" → X-User-Email (user email; mutable, prefer "sub" for stable identity)
+ // - "name" → X-User-Name (display name)
+ // Defaults to ["sub"] when empty, injecting only X-User-Sub.
+ // Including "email" is opt-in to minimise PII forwarded to backends by default.
+ Claims []string `json:"claims,omitempty" yaml:"claims,omitempty"`
+}
+
// RoleMapping defines a rule for mapping JWT claims to IAM roles.
// Mappings are evaluated in priority order (lower number = higher priority).
// +kubebuilder:object:generate=true
diff --git a/pkg/vmcp/auth/types/zz_generated.deepcopy.go b/pkg/vmcp/auth/types/zz_generated.deepcopy.go
index dc67f0f5e5..e20b806dbd 100644
--- a/pkg/vmcp/auth/types/zz_generated.deepcopy.go
+++ b/pkg/vmcp/auth/types/zz_generated.deepcopy.go
@@ -72,6 +72,11 @@ func (in *BackendAuthStrategy) DeepCopyInto(out *BackendAuthStrategy) {
*out = new(AwsStsConfig)
(*in).DeepCopyInto(*out)
}
+ if in.ClaimInjection != nil {
+ in, out := &in.ClaimInjection, &out.ClaimInjection
+ *out = new(ClaimInjectionConfig)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendAuthStrategy.
@@ -84,6 +89,26 @@ func (in *BackendAuthStrategy) DeepCopy() *BackendAuthStrategy {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClaimInjectionConfig) DeepCopyInto(out *ClaimInjectionConfig) {
+ *out = *in
+ if in.Claims != nil {
+ in, out := &in.Claims, &out.Claims
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimInjectionConfig.
+func (in *ClaimInjectionConfig) DeepCopy() *ClaimInjectionConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(ClaimInjectionConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HeaderInjectionConfig) DeepCopyInto(out *HeaderInjectionConfig) {
*out = *in
diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go
index e0c30c0cbe..c5898fddc1 100644
--- a/pkg/vmcp/config/validator.go
+++ b/pkg/vmcp/config/validator.go
@@ -229,6 +229,7 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp
authtypes.StrategyTypeTokenExchange,
authtypes.StrategyTypeUpstreamInject,
authtypes.StrategyTypeAwsSts,
+ authtypes.StrategyTypeClaimInjection,
}
if !slices.Contains(validTypes, strategy.Type) {
return fmt.Errorf("type must be one of: %s", strings.Join(validTypes, ", "))
diff --git a/pkg/vmcp/session/internal/backend/mcp_session.go b/pkg/vmcp/session/internal/backend/mcp_session.go
index 45e1f534cf..6c547cad4c 100644
--- a/pkg/vmcp/session/internal/backend/mcp_session.go
+++ b/pkg/vmcp/session/internal/backend/mcp_session.go
@@ -304,6 +304,9 @@ func createMCPClient(
// the wire. Restricted header names (Host, hop-by-hop, X-Forwarded-*) are
// rejected at resolve time by resolveHeaderForward, so user-supplied
// HeaderForward cannot inject them in the first place.
+ //
+ // User identity claims (X-User-Sub etc.) are injected by authRoundTripper
+ // when outgoingAuth.type = "claim_injection" is configured for the backend.
// The per-transport sections below may add a size-limiting wrapper on top.
base := http.RoundTripper(http.DefaultTransport)
base = &authRoundTripper{
diff --git a/pkg/vmcp/session/internal/backend/roundtripper_test.go b/pkg/vmcp/session/internal/backend/roundtripper_test.go
index e626d57daa..70bbcea1f9 100644
--- a/pkg/vmcp/session/internal/backend/roundtripper_test.go
+++ b/pkg/vmcp/session/internal/backend/roundtripper_test.go
@@ -328,3 +328,4 @@ func TestIdentityRoundTripper_FallbackIdentity_InjectionClonesRequest(t *testing
require.NotNil(t, base.received)
assert.NotSame(t, orig, base.received, "fallback injection should clone the request")
}
+
diff --git a/poc-dockerfile/Dockerfile.vmcp b/poc-dockerfile/Dockerfile.vmcp
new file mode 100644
index 0000000000..2280ebfb52
--- /dev/null
+++ b/poc-dockerfile/Dockerfile.vmcp
@@ -0,0 +1,30 @@
+# Build toolhive vmcp with our ClaimInjectionMiddleware patch
+# Context: ~/github/toolhive (must be built from toolhive root)
+#
+# Build command:
+# docker build -f poc/toolhive-poc/Dockerfile.vmcp -t vmcp-poc:latest ~/github/toolhive
+
+FROM golang:1.26-alpine AS builder
+RUN apk add --no-cache git ca-certificates
+
+WORKDIR /build
+# Copy entire toolhive repo (including our ClaimInjectionMiddleware)
+COPY . .
+
+# Build vmcp binary
+RUN CGO_ENABLED=0 GOOS=linux go build \
+ -ldflags="-s -w -X main.version=poc-dev" \
+ -o /vmcp ./cmd/vmcp
+
+# ---- Runtime image ----
+FROM alpine:3.20
+RUN apk add --no-cache ca-certificates tzdata
+
+COPY --from=builder /vmcp /usr/local/bin/vmcp
+
+# Cloud Run / Docker: accept PORT env var
+EXPOSE 4483
+ENV PORT=4483
+
+ENTRYPOINT ["vmcp"]
+CMD ["serve", "--host", "0.0.0.0", "--port", "4483"]