Skip to content

Commit 01f0513

Browse files
fkztwclaude
andcommitted
feat(transport): add ClaimInjectionMiddleware for backend header injection
Refactor user identity injection to use proper HTTP middleware in the transport layer, replacing the earlier round tripper implementation. The ClaimInjectionMiddleware extracts the authenticated user's identity from request context (populated by auth middleware) and injects it as HTTP headers into requests forwarded to backend MCP servers: X-User-Sub: the 'sub' claim (Google/OIDC user ID) X-User-Email: the 'email' claim (when present) X-User-Name: the 'name' claim (when present) This allows backend MCP servers to identify the calling user without implementing their own OAuth token validation or /introspect calls. The middleware is wired into the HTTP transport chain in http.go, after the existing oauth-token-injection middleware. When no identity is present in context (anonymous request), the middleware is a no-op — no headers are injected. Also includes poc-dockerfile/Dockerfile.vmcp for building the vmcp image with this patch applied via Google Cloud Build. Signed-off-by: Frank Zheng <frank@tagtoo.com> Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 1122157 commit 01f0513

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

pkg/transport/http.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ func (t *HTTPTransport) Start(ctx context.Context) error {
330330
})
331331
}
332332

333+
// Always inject user identity claims (sub, email, name) as headers so backend MCP servers
334+
// can identify the authenticated user without needing to call /introspect themselves.
335+
// When no identity is in context (anonymous request), this middleware is a no-op.
336+
middlewares = append(middlewares, types.NamedMiddleware{
337+
Name: "claim-injection",
338+
Function: middleware.NewClaimInjectionMiddleware(),
339+
})
340+
333341
// Determine whether to enable health checks based on workload type
334342
enableHealthCheck := shouldEnableHealthCheck(isRemote)
335343

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package middleware
5+
6+
import (
7+
"net/http"
8+
9+
"github.com/stacklok/toolhive/pkg/auth"
10+
)
11+
12+
const (
13+
// HeaderUserSub is the HTTP header name for forwarding the authenticated user's subject claim.
14+
// Backend MCP servers can read this header to identify the user without calling /introspect.
15+
HeaderUserSub = "X-User-Sub"
16+
// HeaderUserEmail is the HTTP header name for forwarding the authenticated user's email claim.
17+
HeaderUserEmail = "X-User-Email"
18+
// HeaderUserName is the HTTP header name for forwarding the authenticated user's name claim.
19+
HeaderUserName = "X-User-Name"
20+
)
21+
22+
// NewClaimInjectionMiddleware returns a middleware that extracts user identity from the
23+
// request context (populated by auth middleware) and injects it as HTTP headers into the
24+
// forwarded request. This allows backend MCP servers to receive user identity without
25+
// needing to implement their own OAuth token validation or /introspect calls.
26+
//
27+
// Headers injected (when identity is present):
28+
// - X-User-Sub: the 'sub' claim (Google/OIDC user ID, always present)
29+
// - X-User-Email: the 'email' claim (if available in token)
30+
// - X-User-Name: the 'name' claim (if available in token)
31+
//
32+
// This middleware is safe to add unconditionally: if no identity is present in context
33+
// (e.g., anonymous request), no headers are injected.
34+
func NewClaimInjectionMiddleware() func(http.Handler) http.Handler {
35+
return func(next http.Handler) http.Handler {
36+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+
identity, ok := auth.IdentityFromContext(r.Context())
38+
if ok && identity != nil {
39+
// Clone request to avoid modifying the original
40+
r = r.Clone(r.Context())
41+
if identity.Subject != "" {
42+
r.Header.Set(HeaderUserSub, identity.Subject)
43+
}
44+
if identity.Email != "" {
45+
r.Header.Set(HeaderUserEmail, identity.Email)
46+
}
47+
if identity.Name != "" {
48+
r.Header.Set(HeaderUserName, identity.Name)
49+
}
50+
}
51+
next.ServeHTTP(w, r)
52+
})
53+
}
54+
}

poc-dockerfile/Dockerfile.vmcp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Build toolhive vmcp with our ClaimInjectionMiddleware patch
2+
# Context: ~/github/toolhive (must be built from toolhive root)
3+
#
4+
# Build command:
5+
# docker build -f poc/toolhive-poc/Dockerfile.vmcp -t vmcp-poc:latest ~/github/toolhive
6+
7+
FROM golang:1.26-alpine AS builder
8+
RUN apk add --no-cache git ca-certificates
9+
10+
WORKDIR /build
11+
# Copy entire toolhive repo (including our ClaimInjectionMiddleware)
12+
COPY . .
13+
14+
# Build vmcp binary
15+
RUN CGO_ENABLED=0 GOOS=linux go build \
16+
-ldflags="-s -w -X main.version=poc-dev" \
17+
-o /vmcp ./cmd/vmcp
18+
19+
# ---- Runtime image ----
20+
FROM alpine:3.20
21+
RUN apk add --no-cache ca-certificates tzdata
22+
23+
COPY --from=builder /vmcp /usr/local/bin/vmcp
24+
25+
# Cloud Run / Docker: accept PORT env var
26+
EXPOSE 4483
27+
ENV PORT=4483
28+
29+
ENTRYPOINT ["vmcp"]
30+
CMD ["serve", "--host", "0.0.0.0", "--port", "4483"]

0 commit comments

Comments
 (0)