Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4d893c1
feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1…
cliffhall May 30, 2026
718aa59
feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)
cliffhall May 30, 2026
cc294ba
fix(auth): persist OAuth pre-redirect Network log across the redirect…
cliffhall May 30, 2026
33ae753
feat(network): show auth response bodies with masked secrets + reveal…
cliffhall May 30, 2026
025f06a
fix(auth): inject token into prod SPA fallback + prime sessionStorage…
cliffhall May 31, 2026
6bc0e3c
Merge branch 'v2/auto-inject-api-token' into v2/wire-oauth-flow
cliffhall May 31, 2026
1e9825a
Merge branch 'v2/wire-oauth-flow' into v2/oauth-network-persist
cliffhall May 31, 2026
55baa30
Merge branch 'v2/oauth-network-persist' into v2/auth-response-bodies
cliffhall May 31, 2026
cfbe8be
Merge branch 'v2/main' into v2/wire-oauth-flow
cliffhall May 31, 2026
b5de744
fix(auth): address #1383 review — split OAuth/connect toasts, tighten…
cliffhall May 31, 2026
76c81f7
Merge branch 'v2/wire-oauth-flow' into v2/oauth-network-persist
cliffhall May 31, 2026
29e48e0
Merge branch 'v2/oauth-network-persist' into v2/auth-response-bodies
cliffhall May 31, 2026
669c38d
Merge branch 'v2/main' into v2/oauth-network-persist
cliffhall May 31, 2026
89c06ec
docs(auth): address #1385 review — clarify double-save + keepalive ca…
cliffhall May 31, 2026
98eefd3
Merge branch 'v2/oauth-network-persist' into v2/auth-response-bodies
cliffhall May 31, 2026
5274a8d
Merge branch 'v2/main' into v2/auth-response-bodies
cliffhall May 31, 2026
88aa583
feat(network): address #1387 review — form-encoded masking, cleaner f…
cliffhall May 31, 2026
e499830
refactor(network): address #1387 third-pass review — content-type mas…
cliffhall May 31, 2026
af7ad0d
test(network): address #1387 fourth-pass nits — content-type contract…
cliffhall May 31, 2026
2237d2d
perf(network): address #1387 fifth-pass — memoize masking, mask DCR m…
cliffhall May 31, 2026
06fcae0
docs(network): clarify body-update emits list event only (#1387 sixth…
cliffhall May 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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",
};
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NetworkEntry entry={authEntry} isListExpanded={true} />,
);
// 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(
<NetworkEntry entry={authEntry} isListExpanded={true} />,
);
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(<NetworkEntry entry={baseEntry} isListExpanded={true} />);
expect(screen.queryByText("Secrets hidden")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Reveal secrets in body" }),
).not.toBeInTheDocument();
});
});
81 changes: 76 additions & 5 deletions clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Badge,
Button,
Expand All @@ -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;
Expand Down Expand Up @@ -126,16 +127,78 @@ function HeadersTable({ headers }: { headers: Record<string, string> }) {
);
}

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
// `<BodyPreview>` 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 (
<Text size="xs" c="dimmed">
Body too large to preview ({body.length} characters)
</Text>
);
}
return <ContentViewer block={{ type: "text", text: body }} copyable />;

if (!hasSecrets) {
return <ContentViewer block={{ type: "text", text: body }} copyable />;
}

const shown = revealed ? body : masked;
return (
<Stack gap="xs">
<Group gap="xs">
<Text size="xs" c="dimmed" aria-live="polite">
{revealed ? "Secrets revealed" : "Secrets hidden"}
</Text>
<RevealButton
onClick={() => setRevealed((v) => !v)}
aria-label={
revealed ? "Hide secrets in body" : "Reveal secrets in body"
}
>
{revealed ? "Hide" : "Reveal"}
</RevealButton>
</Group>
<ContentViewer block={{ type: "text", text: shown }} copyable />
</Stack>
);
}

export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
Expand Down Expand Up @@ -192,7 +255,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
<Text size="sm" fw={500}>
Request Body
</Text>
<BodyPreview body={entry.requestBody} />
<BodyPreview
key={`${entry.requestHeaders["content-type"] ?? ""}|${entry.requestBody}`}
body={entry.requestBody}
contentType={entry.requestHeaders["content-type"]}
/>
</Stack>
)}
{entry.responseHeaders && (
Expand All @@ -209,7 +276,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
Response Body
</Text>
{entry.responseBody ? (
<BodyPreview body={entry.responseBody} />
<BodyPreview
key={`${entry.responseHeaders?.["content-type"] ?? ""}|${entry.responseBody}`}
body={entry.responseBody}
contentType={entry.responseHeaders?.["content-type"]}
/>
) : (
<Text size="xs" c="dimmed">
{isLongLivedStream(entry)
Expand Down
Loading
Loading