feat(ui): build secret headers into the MCP server create and edit payloads

This commit is contained in:
shrey kharbanda 2026-11-10 14:47:49 -08:00
parent 2112645192
commit 04509b4886
7 changed files with 74 additions and 3 deletions

View file

@ -239,6 +239,32 @@ describe("buildCreateServerPayload credentials", () => {
});
});
describe("buildCreateServerPayload secret headers", () => {
const rows = [
{ header: " DD-API-KEY ", value: " api-secret " },
{ header: "DD-APPLICATION-KEY", value: "app-secret" },
];
it("sends the rows as credentials.headers and never as a top-level field", () => {
const payload = payloadOf(build({ auth_type: "secret_headers", secret_headers: rows }));
expect(payload.credentials).toEqual({
headers: { "DD-API-KEY": "api-secret", "DD-APPLICATION-KEY": "app-secret" },
});
expect(payload).not.toHaveProperty("secret_headers");
});
it("leaves out rows that a different auth type left behind", () => {
const payload = payloadOf(build({ auth_type: "api_key", credentials: { auth_value: "k" }, secret_headers: rows }));
expect(payload.credentials).toEqual({ auth_value: "k" });
expect(payload).not.toHaveProperty("secret_headers");
});
it("omits credentials when no row names a header", () => {
const payload = payloadOf(build({ auth_type: "secret_headers", secret_headers: [{ header: " ", value: "v" }] }));
expect(payload).not.toHaveProperty("credentials");
});
});
describe("buildCreateServerPayload flags", () => {
it.each([["true_passthrough"], ["oauth_delegate"]])("defaults dcr_bridge on for %s", (authType) => {
expect(payloadOf(build({ auth_type: authType })).dcr_bridge).toBe(true);

View file

@ -24,6 +24,7 @@ export const AUTH_TYPES_REQUIRING_CREDENTIALS = [
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.OAUTH2_ID_JAG,
AUTH_TYPE.AWS_SIGV4,
AUTH_TYPE.SECRET_HEADERS,
AUTH_TYPE.TRUE_PASSTHROUGH,
AUTH_TYPE.OAUTH_DELEGATE,
];
@ -75,6 +76,15 @@ export const reduceStaticHeaders = (list: unknown): Record<string, string> => {
}, {});
};
export const withSecretHeaders = (
credentials: Readonly<Record<string, unknown>> | undefined,
authType: string | undefined,
secretHeadersList: unknown,
): Readonly<Record<string, unknown>> | undefined => {
const headers = authType === AUTH_TYPE.SECRET_HEADERS ? reduceStaticHeaders(secretHeadersList) : {};
return Object.keys(headers).length > 0 ? { ...credentials, headers } : credentials;
};
// Accepts both the full `{"mcpServers": {"name": {...}}}` shape a user copies out of a client config
// and a bare `{"command": ..., "args": ..., "env": ...}`. A non-object JSON body (null, a number)
// falls through to the invalid branch, which is what the caller surfaces to the admin.
@ -137,6 +147,7 @@ export const buildCreateServerPayload = (
const {
static_headers: staticHeadersList,
secret_headers: secretHeadersList,
env_vars: envVarsList,
stdio_config: rawStdioConfig,
credentials: credentialValues,
@ -172,7 +183,7 @@ export const buildCreateServerPayload = (
const transport = restValues.transport === TRANSPORT.OPENAPI ? "http" : restValues.transport;
const authType = restValues.auth_type as string | undefined;
const credentialsPayload = filterCredentials(credentialValues);
const credentialsPayload = withSecretHeaders(filterCredentials(credentialValues), authType, secretHeadersList);
const includeCredentials = authType !== undefined && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(authType);
// Client-forwarded rows persist ONLY the declared app; strip any token material that lingered in
// the form (e.g. from a prior oauth2 authorize on the same session) so it can never reach the row.

View file

@ -87,6 +87,7 @@ const AUTH_TYPES = [
"oauth2_token_exchange",
"oauth2_id_jag",
"aws_sigv4",
"secret_headers",
"true_passthrough",
"oauth_delegate",
] as const;

View file

@ -358,6 +358,32 @@ describe("buildEditServerPayload wire contract", () => {
);
});
describe("buildEditServerPayload secret headers", () => {
const rows = [{ header: "DD-API-KEY", value: "rotated-secret" }];
const payloadOf = (values: EditServerFormValues) => {
const result = buildEditServerPayload({ ...EDIT_FORM_VALUES, auth_type: "secret_headers", ...values }, baseUi);
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
return result.payload;
};
it("replaces the stored headers when rows are entered", () => {
const payload = payloadOf({ secret_headers: rows });
expect(payload.credentials).toEqual({ headers: { "DD-API-KEY": "rotated-secret" } });
expect(payload).not.toHaveProperty("secret_headers");
});
it("sends no credentials, so the stored headers are kept, when no rows are entered", () => {
expect(payloadOf({ secret_headers: [] })).not.toHaveProperty("credentials");
});
it("ignores rows once the server is switched to another auth type", () => {
const payload = payloadOf({ auth_type: "none", secret_headers: rows });
expect(payload).not.toHaveProperty("credentials");
expect(payload).not.toHaveProperty("secret_headers");
});
});
void ADMIN_CONFIG_CREDENTIAL_KEYS;
void AUTH_TYPE;
void AUTH_TYPES_REQUIRING_CREDENTIALS;

View file

@ -14,7 +14,7 @@ import {
isClientForwardedTokenMode,
preservedAdminCredentials,
} from "@/components/mcp_tools/types";
import { AUTH_TYPES_REQUIRING_CREDENTIALS, reduceStaticHeaders } from "./createServerPayload";
import { AUTH_TYPES_REQUIRING_CREDENTIALS, reduceStaticHeaders, withSecretHeaders } from "./createServerPayload";
import { TOOL_DISPLAY_NAME_PATTERN, normalizeEnvVars } from "./utils";
export type MCPAccessGroupValue = string | { readonly name?: string };
@ -288,6 +288,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
const {
static_headers: staticHeadersList,
secret_headers: secretHeadersList,
env_vars: envVarsList,
credentials: credentialValues,
stdio_config: rawStdioConfig,
@ -308,7 +309,11 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
);
const staticHeaders = reduceStaticHeaders(staticHeadersList);
const envVars = normalizeEnvVars(envVarsList);
const credentialsPayload = buildCredentials(credentialValues);
const credentialsPayload = withSecretHeaders(
buildCredentials(credentialValues),
rawRestValues.auth_type,
secretHeadersList,
);
const stdio =
rawRestValues.transport === "stdio"

View file

@ -69,6 +69,7 @@ export interface McpEnvVarRow {
export interface McpListValues {
static_headers: McpStaticHeaderRow[];
secret_headers: McpStaticHeaderRow[];
env_vars: McpEnvVarRow[];
}

View file

@ -43,6 +43,7 @@ export const AUTH_TYPE = {
OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange",
OAUTH2_ID_JAG: "oauth2_id_jag",
AWS_SIGV4: "aws_sigv4",
SECRET_HEADERS: "secret_headers",
TRUE_PASSTHROUGH: "true_passthrough",
OAUTH_DELEGATE: "oauth_delegate",
};