import { beforeAll, describe, expect, it } from "@effect/vitest";import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";import { loadMcpClientSdk } from "./client-module";// Classification consults the lazily-loaded client module (client-module.ts);// in prod every SDK error is preceded by a connect, which loads it. Mirror// that precondition here — these tests construct SDK errors directly.beforeAll(() => loadMcpClientSdk());// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errorsimport { insufficientScopeFromCause } from "./http-status";// The MCP SDK surfaces a 403 two ways, and both must classify:// - no authProvider: the transport throws with the response body embedded// in the message ("Error POSTing to endpoint: <body>");// - with an authProvider (the production OAuth path): the StreamableHTTP// transport consumes the insufficient_scope challenge itself, retries// with the broader scope, and only when THAT fails throws the fixed// typed `InsufficientScopeError`, or after retry exhaustion the fixed// `SdkHttpError` step-up message.describe("insufficientScopeFromCause", () => { it("detects the OAuth error body embedded in a transport message", () => { expect( insufficientScopeFromCause( new Error( 'Error POSTing to endpoint: {"error":"insufficient_scope","error_description":"needs files.read"}', ), ), ).toBe(true); }); it("detects a Google ErrorInfo body embedded in a transport message", () => { expect( insufficientScopeFromCause( new Error( 'Error POSTing to endpoint: {"error":{"details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}', ), ), ).toBe(true); }); it("detects the SDK's typed insufficient-scope failure", () => { expect( insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })), ).toBe(true); }); it("detects the SDK's exhausted step-up failure (the authProvider path)", () => { expect( insufficientScopeFromCause( new SdkHttpError( SdkErrorCode.ClientHttpForbidden, "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)", { status: 403 }, ), ), ).toBe(true); }); it("ignores prose that merely mentions the tokens", () => { expect( insufficientScopeFromCause( new Error( "Error POSTing to endpoint: see the OAuth docs about insufficient_scope handling", ), ), ).toBe(false); }); it("ignores non-error causes", () => { expect(insufficientScopeFromCause(undefined)).toBe(false); expect(insufficientScopeFromCause("insufficient_scope")).toBe(false); });});
import { beforeAll, describe, expect, it } from "@effect/vitest";
import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
import { loadMcpClientSdk } from "./client-module";
// Classification consults the lazily-loaded client module (client-module.ts);
// in prod every SDK error is preceded by a connect, which loads it. Mirror
// that precondition here — these tests construct SDK errors directly.
beforeAll(() => loadMcpClientSdk());
// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
import { insufficientScopeFromCause } from "./http-status";
// The MCP SDK surfaces a 403 two ways, and both must classify:
// - no authProvider: the transport throws with the response body embedded
// in the message ("Error POSTing to endpoint: <body>");
// - with an authProvider (the production OAuth path): the StreamableHTTP
// transport consumes the insufficient_scope challenge itself, retries
// with the broader scope, and only when THAT fails throws the fixed
// typed `InsufficientScopeError`, or after retry exhaustion the fixed
// `SdkHttpError` step-up message.
describe("insufficientScopeFromCause", () => {
it("detects the OAuth error body embedded in a transport message", () => {
expect(
insufficientScopeFromCause(
new Error(
'Error POSTing to endpoint: {"error":"insufficient_scope","error_description":"needs files.read"}',
),
),
).toBe(true);
});
it("detects a Google ErrorInfo body embedded in a transport message", () => {
expect(
insufficientScopeFromCause(
new Error(
'Error POSTing to endpoint: {"error":{"details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}',
),
),
).toBe(true);
});
it("detects the SDK's typed insufficient-scope failure", () => {
expect(
insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),
).toBe(true);
});
it("detects the SDK's exhausted step-up failure (the authProvider path)", () => {
expect(
insufficientScopeFromCause(
new SdkHttpError(
SdkErrorCode.ClientHttpForbidden,
"Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)",
{ status: 403 },
),
),
).toBe(true);
});
it("ignores prose that merely mentions the tokens", () => {
expect(
insufficientScopeFromCause(
new Error(
"Error POSTing to endpoint: see the OAuth docs about insufficient_scope handling",
),
),
).toBe(false);
});
it("ignores non-error causes", () => {
expect(insufficientScopeFromCause(undefined)).toBe(false);
expect(insufficientScopeFromCause("insufficient_scope")).toBe(false);
});
});