fix(oauth): stop claiming the opt-out removes every offline_access

Copilot review round 2 on #2114. The setting was described as
guaranteeing "no offline_access, no forced prompt", which is not what
the SDK does.

`startAuthorization` appends `prompt=consent` for any `offline_access`
in the effective scope and never consults `grant_types`, and
`determineScope` skips its augmentation when the scope already carries
it. So the prompt still goes out when the user configured
`offline_access` in Scopes, or left Scopes blank and the resource
advertises it in `scopes_supported`. The flag removes the SDK's
*automatic* augmentation and nothing more; the docs, the help text and
the type say that now.

The form also flags the case a user can act on: opted out while
`offline_access` is still listed in Scopes. Whole-token match, so
`offline_access_extra` does not trip it.

Corrects the Clear stored OAuth state claim too. It clears the local
tokens and client information — it is not a revocation and cannot
reach the AS. A dynamically registered client re-registers on the next
connect (the new registration declaring only `authorization_code`); a
preconfigured `oauth.clientId` is reused unchanged.

Adds the two missing seam tests, both confirmed to fail without the
code they cover: App's connect wiring (deleting the spread fails two of
the three) and an /api/servers POST followed by a read-back, since a
200 alone never proved the opt-out persisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 20:35:14 -04:00
commit fca3f1ce67

View file

@ -696,6 +696,104 @@ const fetchLogInstances = (
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
).__fetchLogInstances;
// #2068 — the web connection seam for the refresh-token opt-out. The provider
// and manager tests start from an already-built OAuth config and the runner test
// covers only the CLI/TUI leg, so without this case deleting the
// `requestRefreshToken` spread from App's `oauthFromServer` would leave the
// checkbox persisted but inert with every other new test still green.
describe("App wires the refresh-token opt-out into the client (#2068)", () => {
const HTTP_SERVER = {
id: "A",
name: "PlotRocket",
config: { type: "streamable-http", url: "https://api.example.com/mcp" },
connection: { status: "disconnected" },
};
function mockServersWith(settings?: Partial<InspectorServerSettings>) {
vi.mocked(useServers).mockReturnValue({
servers: [
{
...HTTP_SERVER,
...(settings ? { settings } : {}),
} as unknown as ServerEntry,
],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
}
/** The `oauth` option the mocked InspectorClient constructor was given. */
function constructedOAuth(): { requestRefreshToken?: boolean } | undefined {
const call = vi.mocked(McpIndex.InspectorClient).mock.calls[0];
const options = call?.[1] as
| { oauth?: { requestRefreshToken?: boolean } }
| undefined;
return options?.oauth;
}
let previousUseServers: typeof useServers | undefined;
beforeEach(() => {
vi.clearAllMocks();
clientInstances.length = 0;
previousUseServers = vi.mocked(useServers).getMockImplementation();
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
});
afterEach(() => {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
});
it("passes requestRefreshToken: false when the server opted out", async () => {
const user = userEvent.setup();
mockServersWith({ oauthRequestRefreshToken: false });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(constructedOAuth()?.requestRefreshToken).toBe(false);
});
// The default must not send the key at all — the provider's own default is
// what declares the grant, and a stray `true` here would mask its removal.
it("omits requestRefreshToken when the setting is on", async () => {
const user = userEvent.setup();
mockServersWith({ oauthScopes: "openid" });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const oauth = constructedOAuth();
expect(oauth).toBeDefined();
expect(oauth).not.toHaveProperty("requestRefreshToken");
});
// The opt-out is the only OAuth field set, so it must be enough on its own to
// materialize the `oauth` option — otherwise it is dropped for any server
// without credentials or scopes, which is the common case here.
it("builds the oauth option from the opt-out alone", async () => {
const user = userEvent.setup();
mockServersWith({ oauthRequestRefreshToken: false });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(constructedOAuth()).toEqual({ requestRefreshToken: false });
});
});
describe("App failed-connection card border (#1621)", () => {
beforeEach(() => {
clientInstances.length = 0;