diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx
index e056c23b2..6d9f64df5 100644
--- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx
+++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js";
import { NetworkEntry } from "./NetworkEntry";
@@ -38,7 +39,13 @@ const authEntry: FetchRequestEntry = {
responseStatus: 200,
responseStatusText: "OK",
responseHeaders: { "content-type": "application/json" },
- responseBody: '{"access_token":"x","token_type":"bearer"}',
+ responseBody: JSON.stringify({
+ access_token: "eyJhbGciOiJSUzI1NiwidHlwIjoiSldUIn0.payload.sig",
+ token_type: "Bearer",
+ expires_in: 3600,
+ refresh_token: "rt_9f8e7d6c5b4a",
+ scope: "mcp:tools",
+ }),
duration: 120,
category: "auth",
};
@@ -99,6 +106,30 @@ export const TransportSuccessExpanded: Story = {
export const AuthSuccess: Story = {
args: { entry: authEntry, isListExpanded: true },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Both bodies carry secrets: the form request (`code=…`) and the JSON
+ // response (`access_token`). Both are masked by default — the raw values
+ // must not be visible until explicitly revealed.
+ const hidden = canvas.getAllByText("Secrets hidden");
+ await expect(hidden.length).toBeGreaterThanOrEqual(2);
+ await expect(canvasElement.textContent).not.toContain("eyJhbGciOiJSUzI1");
+ await expect(canvasElement.textContent).toContain("••••••••");
+ // Non-secret fields stay visible.
+ await expect(canvasElement.textContent).toContain("Bearer");
+
+ // Reveal every masked body and confirm the raw response token appears.
+ const revealButtons = canvas.getAllByRole("button", {
+ name: "Reveal secrets in body",
+ });
+ for (const button of revealButtons) {
+ await userEvent.click(button);
+ }
+ await expect(
+ canvas.getAllByText("Secrets revealed").length,
+ ).toBeGreaterThanOrEqual(revealButtons.length);
+ await expect(canvasElement.textContent).toContain("eyJhbGciOiJSUzI1");
+ },
};
export const HttpError: Story = {
diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx
index 6dc1381a2..514e78e80 100644
--- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx
+++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx
@@ -196,4 +196,79 @@ describe("NetworkEntry", () => {
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText(/Body too large to preview/)).toBeInTheDocument();
});
+
+ it("masks token-response secrets until revealed, then shows the raw value", async () => {
+ const user = userEvent.setup();
+ const authEntry: FetchRequestEntry = {
+ ...baseEntry,
+ category: "auth",
+ url: "http://localhost:3001/token",
+ requestBody: undefined,
+ responseBody: JSON.stringify({
+ access_token: "super-secret-token",
+ token_type: "Bearer",
+ }),
+ };
+ const { container } = renderWithMantine(
+ ,
+ );
+ // Masked by default: the reveal affordance is present and the raw secret
+ // is nowhere in the DOM, but non-secret fields still render.
+ expect(screen.getByText("Secrets hidden")).toBeInTheDocument();
+ expect(container.textContent).not.toContain("super-secret-token");
+ expect(container.textContent).toContain("••••••••");
+ expect(container.textContent).toContain("Bearer");
+
+ await user.click(
+ screen.getByRole("button", { name: "Reveal secrets in body" }),
+ );
+
+ expect(screen.getByText("Secrets revealed")).toBeInTheDocument();
+ expect(container.textContent).toContain("super-secret-token");
+
+ // Toggling back re-masks.
+ await user.click(
+ screen.getByRole("button", { name: "Hide secrets in body" }),
+ );
+ expect(container.textContent).not.toContain("super-secret-token");
+ });
+
+ it("masks a form-encoded request body (code/verifier) until revealed", async () => {
+ const user = userEvent.setup();
+ const authEntry: FetchRequestEntry = {
+ ...baseEntry,
+ category: "auth",
+ url: "http://localhost:3001/token",
+ requestHeaders: { "content-type": "application/x-www-form-urlencoded" },
+ requestBody:
+ "grant_type=authorization_code&code=SECRETCODE&code_verifier=SECRETVERIFIER",
+ responseStatus: undefined,
+ responseStatusText: undefined,
+ responseHeaders: undefined,
+ responseBody: undefined,
+ };
+ const { container } = renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("Secrets hidden")).toBeInTheDocument();
+ expect(container.textContent).not.toContain("SECRETCODE");
+ expect(container.textContent).not.toContain("SECRETVERIFIER");
+ expect(container.textContent).toContain("••••••••");
+ // Non-secret param stays visible.
+ expect(container.textContent).toContain("grant_type=authorization_code");
+
+ await user.click(
+ screen.getByRole("button", { name: "Reveal secrets in body" }),
+ );
+ expect(container.textContent).toContain("SECRETCODE");
+ expect(container.textContent).toContain("SECRETVERIFIER");
+ });
+
+ it("does not add a reveal toggle for non-secret bodies", () => {
+ renderWithMantine();
+ expect(screen.queryByText("Secrets hidden")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Reveal secrets in body" }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
index b7040832d..7cf8da595 100644
--- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
+++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import {
Badge,
Button,
@@ -13,6 +13,7 @@ import {
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
+import { maskSecretsInBody } from "../../../utils/maskSecrets";
export interface NetworkEntryProps {
entry: FetchRequestEntry;
@@ -126,8 +127,47 @@ function HeadersTable({ headers }: { headers: Record }) {
);
}
-function BodyPreview({ body }: { body: string }) {
+const RevealButton = Button.withProps({
+ variant: "subtle",
+ size: "compact-xs",
+});
+
+function BodyPreview({
+ body,
+ contentType,
+}: {
+ body: string;
+ contentType?: string;
+}) {
+ // Reveal state for masked secrets. Hooks run before any early return so the
+ // order stays stable across the too-large / has-secrets branches. The reveal
+ // state resets when the body or its content-type changes because callers key
+ // `` by both (remounting on swap), so a previously-revealed view
+ // never persists across a content (or masking) change.
+ const [revealed, setRevealed] = useState(false);
+
const tooLarge = body.length > MAX_INLINE_BODY_CHARS;
+
+ // OAuth responses (token exchange, DCR) and the token request carry
+ // bearer-grade secrets. Mask them by default and gate the raw values behind
+ // an explicit reveal so they aren't exposed at a glance during a
+ // screen-share. The entry's content-type scopes which parser runs (so a
+ // plaintext/HTML error body is never guessed at). Bodies without secrets
+ // render as-is with no toggle.
+ //
+ // Memoized so a Reveal/Hide click (a re-render) doesn't re-parse and re-walk
+ // the body; the cost is paid once per mount, and the `key={…}` remount on
+ // body/content-type change re-runs it. Skipped for too-large bodies so we
+ // never parse something we won't display (the hook must run unconditionally,
+ // hence the in-memo guard rather than an early return above it).
+ const { masked, hasSecrets } = useMemo(
+ () =>
+ tooLarge
+ ? { masked: body, hasSecrets: false }
+ : maskSecretsInBody(body, contentType),
+ [tooLarge, body, contentType],
+ );
+
if (tooLarge) {
return (
@@ -135,7 +175,30 @@ function BodyPreview({ body }: { body: string }) {
);
}
- return ;
+
+ if (!hasSecrets) {
+ return ;
+ }
+
+ const shown = revealed ? body : masked;
+ return (
+
+
+
+ {revealed ? "Secrets revealed" : "Secrets hidden"}
+
+ setRevealed((v) => !v)}
+ aria-label={
+ revealed ? "Hide secrets in body" : "Reveal secrets in body"
+ }
+ >
+ {revealed ? "Hide" : "Reveal"}
+
+
+
+
+ );
}
export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
@@ -192,7 +255,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
Request Body
-
+
)}
{entry.responseHeaders && (
@@ -209,7 +276,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
Response Body
{entry.responseBody ? (
-
+
) : (
{isLongLivedStream(entry)
diff --git a/clients/web/src/utils/maskSecrets.test.ts b/clients/web/src/utils/maskSecrets.test.ts
new file mode 100644
index 000000000..39c8dac15
--- /dev/null
+++ b/clients/web/src/utils/maskSecrets.test.ts
@@ -0,0 +1,205 @@
+import { describe, it, expect } from "vitest";
+import { maskSecretsInBody, MASK_PLACEHOLDER } from "./maskSecrets";
+
+describe("maskSecretsInBody", () => {
+ it("masks token fields in a token-exchange response and flags secrets", () => {
+ const body = JSON.stringify({
+ access_token: "abc.def.ghi",
+ token_type: "Bearer",
+ expires_in: 3600,
+ refresh_token: "r3fr3sh",
+ scope: "mcp:tools",
+ });
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ const parsed = JSON.parse(masked);
+ expect(parsed.access_token).toBe(MASK_PLACEHOLDER);
+ expect(parsed.refresh_token).toBe(MASK_PLACEHOLDER);
+ // Non-secret fields pass through untouched.
+ expect(parsed.token_type).toBe("Bearer");
+ expect(parsed.expires_in).toBe(3600);
+ expect(parsed.scope).toBe("mcp:tools");
+ // The raw secret never appears in the masked output.
+ expect(masked).not.toContain("abc.def.ghi");
+ expect(masked).not.toContain("r3fr3sh");
+ });
+
+ it("masks id_token and client_secret (case-insensitive keys)", () => {
+ const body = JSON.stringify({
+ ID_Token: "jwt",
+ Client_Secret: "shhh",
+ client_id: "public-123",
+ });
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ const parsed = JSON.parse(masked);
+ expect(parsed.ID_Token).toBe(MASK_PLACEHOLDER);
+ expect(parsed.Client_Secret).toBe(MASK_PLACEHOLDER);
+ // client_id is not a secret.
+ expect(parsed.client_id).toBe("public-123");
+ });
+
+ it("masks secrets nested in objects and arrays", () => {
+ const body = JSON.stringify({
+ data: { tokens: [{ access_token: "deep" }] },
+ });
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ expect(JSON.parse(masked).data.tokens[0].access_token).toBe(
+ MASK_PLACEHOLDER,
+ );
+ });
+
+ it("masks a non-string value under a sensitive key wholesale (no leak via recursion)", () => {
+ // A non-standard server wrapping the token in an object: the value must be
+ // replaced wholesale rather than recursed into (where the inner `value`
+ // key isn't sensitive and would otherwise leak).
+ const body = JSON.stringify({
+ access_token: { value: "leaky-secret", expires_in: 3600 },
+ });
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ expect(masked).not.toContain("leaky-secret");
+ expect(JSON.parse(masked).access_token).toBe(MASK_PLACEHOLDER);
+ });
+
+ it("masks a confidential-client DCR (/register) response", () => {
+ // RFC 7591/7592 happy path: client_secret and the registration management
+ // token are bearer-grade and masked; client_id and metadata stay visible.
+ const body = JSON.stringify({
+ client_id: "dyn-123",
+ client_secret: "REG-SECRET",
+ registration_access_token: "REG-RAT",
+ registration_client_uri: "http://localhost:3001/register/dyn-123",
+ client_id_issued_at: 1735689600,
+ });
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ const parsed = JSON.parse(masked);
+ expect(parsed.client_secret).toBe(MASK_PLACEHOLDER);
+ expect(parsed.registration_access_token).toBe(MASK_PLACEHOLDER);
+ expect(masked).not.toContain("REG-SECRET");
+ expect(masked).not.toContain("REG-RAT");
+ // Non-secret fields untouched.
+ expect(parsed.client_id).toBe("dyn-123");
+ expect(parsed.registration_client_uri).toBe(
+ "http://localhost:3001/register/dyn-123",
+ );
+ });
+
+ it("reports no secrets (and leaves content intact) for discovery metadata", () => {
+ const body = JSON.stringify({
+ issuer: "http://localhost:3001/",
+ authorization_endpoint: "http://localhost:3001/authorize",
+ scopes_supported: ["mcp:tools"],
+ });
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(false);
+ expect(JSON.parse(masked).authorization_endpoint).toBe(
+ "http://localhost:3001/authorize",
+ );
+ });
+
+ it("does not flag an empty-string secret value", () => {
+ const { hasSecrets } = maskSecretsInBody(
+ JSON.stringify({ access_token: "" }),
+ );
+ expect(hasSecrets).toBe(false);
+ });
+
+ it("returns a form body with no sensitive params unchanged", () => {
+ const body = "grant_type=authorization_code&redirect_uri=http://x/cb";
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(false);
+ expect(masked).toBe(body);
+ });
+
+ it("masks sensitive params in a form-encoded token request, preserving other params", () => {
+ const body =
+ "grant_type=authorization_code&code=AUTHCODE&code_verifier=VERIFIER&client_id=public-1&redirect_uri=http://x/cb";
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ expect(masked).toContain(`code=${MASK_PLACEHOLDER}`);
+ expect(masked).toContain(`code_verifier=${MASK_PLACEHOLDER}`);
+ expect(masked).not.toContain("AUTHCODE");
+ expect(masked).not.toContain("VERIFIER");
+ // Non-secret params are untouched (and formatting preserved).
+ expect(masked).toContain("grant_type=authorization_code");
+ expect(masked).toContain("client_id=public-1");
+ expect(masked).toContain("redirect_uri=http://x/cb");
+ });
+
+ it("masks refresh_token and client_secret in a form-encoded refresh request", () => {
+ const body =
+ "grant_type=refresh_token&refresh_token=RTVAL&client_secret=CSVAL";
+ const { masked, hasSecrets } = maskSecretsInBody(body);
+ expect(hasSecrets).toBe(true);
+ expect(masked).not.toContain("RTVAL");
+ expect(masked).not.toContain("CSVAL");
+ });
+
+ it("does NOT mask a JSON `code` field (e.g. a JSON-RPC error code)", () => {
+ // `code` is form-only sensitive; in JSON it's usually an error/status code.
+ const { masked, hasSecrets } = maskSecretsInBody(
+ JSON.stringify({ code: "some-string-code", message: "boom" }),
+ );
+ expect(hasSecrets).toBe(false);
+ expect(JSON.parse(masked).code).toBe("some-string-code");
+ });
+
+ it("does not treat pure reformatting as masking", () => {
+ // Minified JSON with no secret keys → reserialized but hasSecrets false.
+ const { hasSecrets } = maskSecretsInBody('{"a":1,"b":[2,3]}');
+ expect(hasSecrets).toBe(false);
+ });
+
+ it("passes through valid-but-non-object JSON (string / number / null) untouched", () => {
+ for (const raw of ['"abc"', "42", "null", "true"]) {
+ const { masked, hasSecrets } = maskSecretsInBody(raw);
+ expect(hasSecrets).toBe(false);
+ expect(JSON.parse(masked)).toEqual(JSON.parse(raw));
+ }
+ });
+
+ it("masks every occurrence of a repeated sensitive form param", () => {
+ const { masked, hasSecrets } = maskSecretsInBody("code=AAA&code=BBB");
+ expect(hasSecrets).toBe(true);
+ expect(masked).toBe(`code=${MASK_PLACEHOLDER}&code=${MASK_PLACEHOLDER}`);
+ });
+
+ it("does not flag an empty form param value", () => {
+ const { masked, hasSecrets } = maskSecretsInBody(
+ "grant_type=refresh_token&client_secret=",
+ );
+ expect(hasSecrets).toBe(false);
+ expect(masked).toBe("grant_type=refresh_token&client_secret=");
+ });
+
+ it("honors an explicit content-type: form params under a JSON type are not form-masked", () => {
+ // `code` is form-only sensitive; with a JSON content-type the body goes
+ // through the JSON parser (fails to parse → untouched), not form masking.
+ const { hasSecrets } = maskSecretsInBody(
+ "code=AAA&code_verifier=BBB",
+ "application/json",
+ );
+ expect(hasSecrets).toBe(false);
+ });
+
+ it("skips masking for a known non-JSON, non-form content-type", () => {
+ // A plaintext/HTML error body that happens to contain `access_token=…`
+ // must not be guessed at as form-encoded when the content-type says text.
+ const body = "Error: access_token=leaked has expired";
+ const { masked, hasSecrets } = maskSecretsInBody(body, "text/plain");
+ expect(hasSecrets).toBe(false);
+ expect(masked).toBe(body);
+ });
+
+ it("uses the form parser for an explicit form content-type", () => {
+ const { masked, hasSecrets } = maskSecretsInBody(
+ "refresh_token=RT",
+ "application/x-www-form-urlencoded",
+ );
+ expect(hasSecrets).toBe(true);
+ expect(masked).toBe(`refresh_token=${MASK_PLACEHOLDER}`);
+ });
+});
diff --git a/clients/web/src/utils/maskSecrets.ts b/clients/web/src/utils/maskSecrets.ts
new file mode 100644
index 000000000..3fd4d9a5d
--- /dev/null
+++ b/clients/web/src/utils/maskSecrets.ts
@@ -0,0 +1,181 @@
+/**
+ * Masks sensitive OAuth values inside a captured HTTP body for display in the
+ * Network tab. OAuth token-exchange / registration responses carry credentials
+ * (`access_token`, `refresh_token`, …) and the token *request* (a
+ * `application/x-www-form-urlencoded` body) carries `code` / `code_verifier` /
+ * `client_secret`. We show the body so it's inspectable, but mask those values
+ * by default so they aren't exposed at a glance during a screen-share. The raw
+ * body is preserved by the caller and shown only when the user reveals it.
+ *
+ * Content-type selects the parser: `*json*` → JSON masking, form-urlencoded →
+ * form masking, any other known type → no masking. When the content-type is
+ * absent/unknown the body is sniffed (parse as JSON first, else treat as
+ * form). See `maskSecretsInBody`.
+ */
+
+// Keys masked in JSON bodies — bearer-grade secrets only. `code` is
+// deliberately NOT here: a JSON body's `code` is usually something else (e.g.
+// a JSON-RPC error `code`), and we don't want to mask those.
+// `registration_access_token` is the DCR management credential (RFC 7592),
+// same bearer class as `access_token`.
+const JSON_SENSITIVE_KEYS = new Set([
+ "access_token",
+ "refresh_token",
+ "id_token",
+ "client_secret",
+ "registration_access_token",
+]);
+
+// Keys masked in form-encoded bodies — the JSON set plus the single-use OAuth
+// request material that only appears as form params (authorization code, PKCE
+// verifier, private-key-JWT client assertion).
+const FORM_SENSITIVE_KEYS = new Set([
+ ...JSON_SENSITIVE_KEYS,
+ "code",
+ "code_verifier",
+ "client_assertion",
+]);
+
+// What a masked value is replaced with. A fixed-width dotted string keeps the
+// shape recognizable as "a value was here" without hinting at its length.
+export const MASK_PLACEHOLDER = "••••••••";
+
+function isSensitiveKey(set: ReadonlySet, key: string): boolean {
+ return set.has(key.toLowerCase());
+}
+
+// Whether a value under a sensitive key should be masked. The contract is
+// "any non-null, non-empty-string value": strings are masked when non-empty
+// (an empty `access_token` carries nothing), and any non-string value
+// (object/array/number/boolean wrapper — pathological for OAuth, but a safe
+// default) is masked wholesale so it can't leak through the recursion.
+function isMaskableValue(value: unknown): boolean {
+ if (value === null || value === undefined) return false;
+ if (typeof value === "string") return value.length > 0;
+ return true;
+}
+
+interface MaskedNode {
+ node: unknown;
+ masked: boolean;
+}
+
+// Recursively mask sensitive values in a parsed JSON node, tracking whether
+// anything was masked (so the caller never has to infer it by comparing
+// serializations — reformatting alone can't trip the flag, and it's robust if
+// this function ever grows non-identity transforms).
+function maskNode(node: unknown): MaskedNode {
+ if (Array.isArray(node)) {
+ let masked = false;
+ const out = node.map((item) => {
+ const r = maskNode(item);
+ masked = masked || r.masked;
+ return r.node;
+ });
+ return { node: out, masked };
+ }
+ if (node !== null && typeof node === "object") {
+ let masked = false;
+ const out: Record = {};
+ for (const [key, value] of Object.entries(
+ node as Record,
+ )) {
+ if (isSensitiveKey(JSON_SENSITIVE_KEYS, key) && isMaskableValue(value)) {
+ out[key] = MASK_PLACEHOLDER;
+ masked = true;
+ } else {
+ const r = maskNode(value);
+ out[key] = r.node;
+ masked = masked || r.masked;
+ }
+ }
+ return { node: out, masked };
+ }
+ return { node, masked: false };
+}
+
+export interface MaskResult {
+ /** The body with sensitive values replaced; pretty-printed for JSON, otherwise the original shape with values substituted. */
+ masked: string;
+ /** True when at least one sensitive value was masked. */
+ hasSecrets: boolean;
+}
+
+function maskJsonBody(body: string): MaskResult {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return { masked: body, hasSecrets: false };
+ }
+ const { node, masked } = maskNode(parsed);
+ return { masked: JSON.stringify(node, null, 2), hasSecrets: masked };
+}
+
+// Mask sensitive params in a form-urlencoded body, preserving the original
+// formatting (we only swap the value, so the placeholder isn't percent-encoded
+// the way `URLSearchParams.toString()` would mangle it). A non-form string
+// (no `key=value` pairs with a sensitive key) falls through untouched.
+function maskFormBody(body: string): MaskResult {
+ let hasSecrets = false;
+ const masked = body
+ .split("&")
+ .map((pair) => {
+ const eq = pair.indexOf("=");
+ if (eq === -1) return pair;
+ const rawKey = pair.slice(0, eq);
+ const value = pair.slice(eq + 1);
+ let key: string;
+ try {
+ key = decodeURIComponent(rawKey);
+ } catch {
+ key = rawKey;
+ }
+ if (isSensitiveKey(FORM_SENSITIVE_KEYS, key) && value.length > 0) {
+ hasSecrets = true;
+ return `${rawKey}=${MASK_PLACEHOLDER}`;
+ }
+ return pair;
+ })
+ .join("&");
+ return { masked: hasSecrets ? masked : body, hasSecrets };
+}
+
+/**
+ * Mask sensitive fields in an HTTP body for display.
+ *
+ * `contentType` (the body's `content-type` header, if known) picks the parser:
+ * - `*json*` → JSON masking (re-serialized pretty)
+ * - `application/x-www-form-urlencoded` → form masking (shape preserved)
+ * - any other known type (HTML, plaintext, XML, …) → no masking
+ * - absent/unknown → sniff: parse as JSON, else treat as form
+ *
+ * Bodies with no sensitive keys return unchanged with `hasSecrets: false` so
+ * callers can skip the reveal affordance. The caller keeps the original string
+ * for the revealed view.
+ *
+ * `contentType` is matched by substring (`*json*`, `*x-www-form-urlencoded*`)
+ * and we trust the wire's own label — a body mislabeled by the server (e.g.
+ * JSON sent as `text/html`) takes the "no masking" branch and renders raw.
+ * That's acceptable: the threat model is a screen-share viewer, not an
+ * adversary who controls the response's content-type.
+ */
+export function maskSecretsInBody(
+ body: string,
+ contentType?: string,
+): MaskResult {
+ const ct = (contentType ?? "").toLowerCase();
+ if (ct) {
+ if (ct.includes("json")) return maskJsonBody(body);
+ if (ct.includes("x-www-form-urlencoded")) return maskFormBody(body);
+ // Known, non-JSON/non-form content type → don't guess; leave it alone.
+ return { masked: body, hasSecrets: false };
+ }
+ // No content-type hint: sniff. Valid JSON → JSON masking; otherwise form.
+ try {
+ JSON.parse(body);
+ } catch {
+ return maskFormBody(body);
+ }
+ return maskJsonBody(body);
+}
diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts
index e0f2254ce..d68b2a6df 100644
--- a/core/mcp/inspectorClient.ts
+++ b/core/mcp/inspectorClient.ts
@@ -315,16 +315,18 @@ export class InspectorClient extends InspectorClientEventTarget {
private buildEffectiveAuthFetch(): typeof fetch {
const base = this.fetchFn ?? fetch;
- // Note: we deliberately do NOT wire `updateResponseBody` for the auth
- // fetcher. OAuth token-exchange responses contain `access_token` and
- // `refresh_token`; capturing them into FetchRequestLogState would
- // surface live credentials in the Network tab body preview, which is
- // easy to leak during a screen-share. Headers + status are still
- // tracked. If a future need calls for inspecting auth bodies, add
- // explicit secret redaction first.
+ // Capture auth response bodies (OAuth discovery, DCR, token exchange) so
+ // they're inspectable in the Network tab. Token-exchange responses carry
+ // `access_token` / `refresh_token`; the Network UI masks those (and other
+ // known secret fields) behind a click-to-reveal toggle so they aren't
+ // surfaced at a glance during a screen-share. Masking is a display
+ // concern, kept in the UI layer rather than mutating the captured entry,
+ // so the raw body stays available for the user who explicitly reveals it.
return createFetchTracker(base, {
trackRequest: (entry) =>
this.dispatchFetchRequest({ ...entry, category: "auth" }),
+ updateResponseBody: (id, body) =>
+ this.dispatchFetchRequestBodyUpdate(id, body),
});
}
diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts
index 35f8f4e6b..9761a0d83 100644
--- a/core/mcp/state/fetchRequestLogState.ts
+++ b/core/mcp/state/fetchRequestLogState.ts
@@ -92,6 +92,11 @@ export class FetchRequestLogState extends TypedEventTarget,
): void => {