feat(coderd/mcp): user-headers GET/PUT/DELETE endpoints

Adds three experimental endpoints under /api/experimental/mcp/servers/
{mcpServer}/user-headers that let a user manage their own values for
the admin-marked custom_headers_user_keys on an MCP server.

- GET returns an MCPServerUserHeaderValues with a HasValues map keyed
  by every admin-marked user key. Values are never returned, only
  booleans indicating whether the caller has stored a non-empty value.
- PUT upserts a subset of values. Unknown keys are rejected with 400.
  Keys are matched case-insensitively but stored under the admin's
  canonical casing. A partial PUT preserves existing values for keys
  the request did not touch; sending an empty string clears a single
  value.
- DELETE removes the row entirely and is idempotent.

The Upsert dbauthz wrapper switches to insertWithAction so the first
write does not require a pre-existing row.
This commit is contained in:
Steven Masley
2026-05-28 23:06:42 +00:00
parent 50255a9ef9
commit 33d7bf1760
5 changed files with 401 additions and 14 deletions
+4
View File
@@ -1367,6 +1367,10 @@ func New(options *Options) *API {
r.Get("/oauth2/connect", api.mcpServerOAuth2Connect)
r.Get("/oauth2/callback", api.mcpServerOAuth2Callback)
r.Delete("/oauth2/disconnect", api.mcpServerOAuth2Disconnect)
// Per-user custom header values for admin-marked keys.
r.Get("/user-headers", api.getMCPServerUserHeaderValues)
r.Put("/user-headers", api.updateMCPServerUserHeaderValues)
r.Delete("/user-headers", api.deleteMCPServerUserHeaderValues)
})
})
// MCP HTTP transport endpoint with mandatory authentication
+1 -7
View File
@@ -8277,13 +8277,7 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error {
}
func (q *querier) UpsertMCPServerUserHeaderValues(ctx context.Context, arg database.UpsertMCPServerUserHeaderValuesParams) (database.McpServerUserHeaderValue, error) {
fetch := func(ctx context.Context, arg database.UpsertMCPServerUserHeaderValuesParams) (database.McpServerUserHeaderValue, error) {
return q.db.GetMCPServerUserHeaderValues(ctx, database.GetMCPServerUserHeaderValuesParams{
MCPServerConfigID: arg.MCPServerConfigID,
UserID: arg.UserID,
})
}
return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpsertMCPServerUserHeaderValues)(ctx, arg)
return insertWithAction(q.log, q.auth, rbac.ResourceUser.WithID(arg.UserID).WithOwner(arg.UserID.String()), policy.ActionUpdatePersonal, q.db.UpsertMCPServerUserHeaderValues)(ctx, arg)
}
func (q *querier) UpsertMCPServerUserToken(ctx context.Context, arg database.UpsertMCPServerUserTokenParams) (database.MCPServerUserToken, error) {
+1 -5
View File
@@ -1684,12 +1684,8 @@ func (s *MethodTestSuite) TestChats() {
HeaderValues: `{"X-User-Token":"secret"}`,
}
value := testutil.Fake(s.T(), faker, database.McpServerUserHeaderValue{MCPServerConfigID: arg.MCPServerConfigID, UserID: arg.UserID})
dbm.EXPECT().GetMCPServerUserHeaderValues(gomock.Any(), database.GetMCPServerUserHeaderValuesParams{
MCPServerConfigID: arg.MCPServerConfigID,
UserID: arg.UserID,
}).Return(value, nil).AnyTimes()
dbm.EXPECT().UpsertMCPServerUserHeaderValues(gomock.Any(), arg).Return(value, nil).AnyTimes()
check.Args(arg).Asserts(value, policy.ActionUpdatePersonal).Returns(value)
check.Args(arg).Asserts(rbac.ResourceUser.WithID(arg.UserID).WithOwner(arg.UserID.String()), policy.ActionUpdatePersonal).Returns(value)
}))
s.Run("DeleteMCPServerUserHeaderValues", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.DeleteMCPServerUserHeaderValuesParams{
+213 -2
View File
@@ -1232,8 +1232,217 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques
rw.WriteHeader(http.StatusNoContent)
}
// parseMCPServerConfigID extracts the MCP server config UUID from the
// "mcpServer" path parameter.
// @Summary Get MCP user-set custom header values
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getMCPServerUserHeaderValues(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
mcpServerID, ok := parseMCPServerConfigID(rw, r)
if !ok {
return
}
// Load the config to know which keys the admin has marked as
// user-set. We use system context because the user can't
// authorize a direct ResourceDeploymentConfig read.
//nolint:gocritic // Users read their own header values; need config metadata to bound the response.
cfg, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
if cfg.AuthType != "custom_headers" || len(cfg.CustomHeadersUserKeys) == 0 {
// No user-set keys; respond with an empty has_values map.
httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerUserHeaderValues{
MCPServerConfigID: cfg.ID,
HasValues: map[string]bool{},
})
return
}
row, err := api.Database.GetMCPServerUserHeaderValues(ctx, database.GetMCPServerUserHeaderValuesParams{
MCPServerConfigID: mcpServerID,
UserID: apiKey.UserID,
})
stored := map[string]string{}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
if err == nil && strings.TrimSpace(row.HeaderValues) != "" {
if decErr := json.Unmarshal([]byte(row.HeaderValues), &stored); decErr != nil {
httpapi.InternalServerError(rw, xerrors.Errorf("decode header_values: %w", decErr))
return
}
}
hasValues := make(map[string]bool, len(cfg.CustomHeadersUserKeys))
for _, key := range cfg.CustomHeadersUserKeys {
hasValues[key] = stored[key] != ""
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerUserHeaderValues{
MCPServerConfigID: cfg.ID,
HasValues: hasValues,
})
}
// @Summary Update MCP user-set custom header values
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) updateMCPServerUserHeaderValues(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
mcpServerID, ok := parseMCPServerConfigID(rw, r)
if !ok {
return
}
var req codersdk.UpdateMCPServerUserHeaderValuesRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
//nolint:gocritic // Users update their own header values; need config metadata to validate the request.
cfg, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
if cfg.AuthType != "custom_headers" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "MCP server does not use custom_headers auth.",
})
return
}
if len(cfg.CustomHeadersUserKeys) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "MCP server has no user-set custom header keys.",
})
return
}
// Build a case-insensitive lookup of allowed user keys, preserving
// the admin's casing for storage.
allowed := make(map[string]string, len(cfg.CustomHeadersUserKeys))
for _, k := range cfg.CustomHeadersUserKeys {
allowed[strings.ToLower(k)] = k
}
// Validate every key in the request matches an allowed user key.
normalized := make(map[string]string, len(req.Values))
for reqKey, reqVal := range req.Values {
canonical, ok := allowed[strings.ToLower(strings.TrimSpace(reqKey))]
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Header %q is not in the MCP server's user-set custom header keys.", reqKey),
})
return
}
if strings.TrimSpace(reqVal) != "" {
normalized[canonical] = reqVal
}
}
// Merge with any existing stored values so a partial update only
// overwrites the keys it touches. A user can clear a single value
// by sending an empty string for that key.
merged := map[string]string{}
existing, err := api.Database.GetMCPServerUserHeaderValues(ctx, database.GetMCPServerUserHeaderValuesParams{
MCPServerConfigID: mcpServerID,
UserID: apiKey.UserID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
if err == nil && strings.TrimSpace(existing.HeaderValues) != "" {
if decErr := json.Unmarshal([]byte(existing.HeaderValues), &merged); decErr != nil {
httpapi.InternalServerError(rw, xerrors.Errorf("decode existing header_values: %w", decErr))
return
}
}
for _, k := range cfg.CustomHeadersUserKeys {
if _, sent := req.Values[k]; !sent {
// Case-insensitive check for the canonical key.
alreadyInRequest := false
for reqKey := range req.Values {
if strings.EqualFold(strings.TrimSpace(reqKey), k) {
alreadyInRequest = true
break
}
}
if alreadyInRequest {
continue
}
// Preserve existing stored value if any.
if v, has := merged[k]; has && v != "" {
normalized[k] = v
}
}
}
encoded, err := json.Marshal(normalized)
if err != nil {
httpapi.InternalServerError(rw, xerrors.Errorf("encode header_values: %w", err))
return
}
if _, err := api.Database.UpsertMCPServerUserHeaderValues(ctx, database.UpsertMCPServerUserHeaderValuesParams{
MCPServerConfigID: mcpServerID,
UserID: apiKey.UserID,
HeaderValues: string(encoded),
HeaderValuesKeyID: sql.NullString{},
}); err != nil {
httpapi.InternalServerError(rw, err)
return
}
hasValues := make(map[string]bool, len(cfg.CustomHeadersUserKeys))
for _, k := range cfg.CustomHeadersUserKeys {
hasValues[k] = normalized[k] != ""
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerUserHeaderValues{
MCPServerConfigID: cfg.ID,
HasValues: hasValues,
})
}
// @Summary Delete MCP user-set custom header values
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) deleteMCPServerUserHeaderValues(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
mcpServerID, ok := parseMCPServerConfigID(rw, r)
if !ok {
return
}
err := api.Database.DeleteMCPServerUserHeaderValues(ctx, database.DeleteMCPServerUserHeaderValuesParams{
MCPServerConfigID: mcpServerID,
UserID: apiKey.UserID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
}
// refreshMCPUserToken attempts to refresh an expired OAuth2 token
// for the given MCP server config. Returns true when the token is
// valid (either still fresh or successfully refreshed), false when
@@ -1295,6 +1504,8 @@ func (api *API) refreshMCPUserToken(
return true
}
// parseMCPServerConfigID extracts the MCP server config UUID from the
// "mcpServer" path parameter.
func parseMCPServerConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
mcpServerID, err := uuid.Parse(chi.URLParam(r, "mcpServer"))
if err != nil {
+182
View File
@@ -2144,3 +2144,185 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) {
require.True(t, created.HasOAuth2Secret)
})
}
// TestMCPServerUserHeaderValuesEndpoints exercises the user-headers
// GET/PUT/DELETE flow for the per-user custom header values.
func TestMCPServerUserHeaderValuesEndpoints(t *testing.T) {
t.Parallel()
createHonchoConfig := func(t *testing.T, client *codersdk.Client) codersdk.MCPServerConfig {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
cfg, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Honcho",
Slug: "honcho",
Transport: "streamable_http",
URL: "https://mcp.example.com/v1",
AuthType: "custom_headers",
CustomHeaders: map[string]string{"X-Org-ID": "acme"},
CustomHeadersUserKeys: []string{"X-User-Token", "X-Workspace"},
Availability: "default_on",
Enabled: true,
})
require.NoError(t, err)
return cfg
}
t.Run("GetReturnsAllFalseWhenNoValuesStored", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
resp, err := client.MCPServerUserHeaderValues(ctx, cfg.ID)
require.NoError(t, err)
require.Equal(t, cfg.ID, resp.MCPServerConfigID)
require.False(t, resp.HasValues["X-User-Token"])
require.False(t, resp.HasValues["X-Workspace"])
})
t.Run("GetReturnsEmptyForNonCustomHeadersConfig", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{
DisplayName: "None",
Slug: "none-cfg",
Transport: "streamable_http",
URL: "https://mcp.example.com/v1",
AuthType: "none",
Availability: "default_on",
Enabled: true,
})
require.NoError(t, err)
resp, err := client.MCPServerUserHeaderValues(ctx, cfg.ID)
require.NoError(t, err)
require.Empty(t, resp.HasValues)
})
t.Run("PutThenGetReportsHasValuesTrue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
putResp, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-User-Token": "secret-jwt"},
})
require.NoError(t, err)
require.True(t, putResp.HasValues["X-User-Token"])
require.False(t, putResp.HasValues["X-Workspace"])
getResp, err := client.MCPServerUserHeaderValues(ctx, cfg.ID)
require.NoError(t, err)
require.True(t, getResp.HasValues["X-User-Token"])
require.False(t, getResp.HasValues["X-Workspace"])
})
t.Run("PutRejectsUnknownKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
_, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-Unknown": "oops"},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("PutPartialUpdatePreservesOtherKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
_, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-User-Token": "jwt-a", "X-Workspace": "main"},
})
require.NoError(t, err)
// Partial update touching only one key; the other must remain set.
putResp, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-User-Token": "jwt-b"},
})
require.NoError(t, err)
require.True(t, putResp.HasValues["X-User-Token"])
require.True(t, putResp.HasValues["X-Workspace"])
})
t.Run("PutClearsSingleValueWithEmptyString", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
_, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-User-Token": "jwt-a"},
})
require.NoError(t, err)
putResp, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-User-Token": ""},
})
require.NoError(t, err)
require.False(t, putResp.HasValues["X-User-Token"])
})
t.Run("DeleteRemovesAllValues", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
_, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"X-User-Token": "jwt-a", "X-Workspace": "main"},
})
require.NoError(t, err)
require.NoError(t, client.DeleteMCPServerUserHeaderValues(ctx, cfg.ID))
getResp, err := client.MCPServerUserHeaderValues(ctx, cfg.ID)
require.NoError(t, err)
require.False(t, getResp.HasValues["X-User-Token"])
require.False(t, getResp.HasValues["X-Workspace"])
})
t.Run("DeleteIsIdempotent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
// Never set a value; delete should still return 204.
require.NoError(t, client.DeleteMCPServerUserHeaderValues(ctx, cfg.ID))
})
t.Run("PutAcceptsCaseInsensitiveKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
_ = coderdtest.CreateFirstUser(t, client)
cfg := createHonchoConfig(t, client)
// Request lowercases the canonical "X-User-Token".
putResp, err := client.UpdateMCPServerUserHeaderValues(ctx, cfg.ID, codersdk.UpdateMCPServerUserHeaderValuesRequest{
Values: map[string]string{"x-user-token": "jwt-a"},
})
require.NoError(t, err)
require.True(t, putResp.HasValues["X-User-Token"], "HasValues should report under the canonical key")
})
}