Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 87 additions & 0 deletions clients/web/src/test/core/auth/challenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
AuthChallengeError,
AuthRecoveryRequiredError,
findNestedAuthError,
isAuthChallengeError,
isConnectAuthRecoveryError,
parseAuthChallengeFromError,
Expand Down Expand Up @@ -391,3 +392,89 @@ describe("isConnectAuthRecoveryError", () => {
).toBe(false);
});
});

/**
* The SDK's era-negotiation probe (protocolEra "auto"/"modern") reports a failed
* `server/discover` as `SdkError(ERA_NEGOTIATION_FAILED)` and moves the real
* error to `data.cause`, hiding the auth signal connect-time recovery matches on
* (#1805). These cover the recovery walk over both link names.
*/
describe("findNestedAuthError", () => {
const authorizationUrl = new URL("https://as.example/authorize");
const recoveryRequired = () =>
new AuthRecoveryRequiredError(authorizationUrl, { reason: "unauthorized" });

it("recovers an AuthRecoveryRequiredError from `data.cause` (the SDK probe wrapper)", () => {
const nested = recoveryRequired();
const wrapper = new Error(
"Version negotiation probe failed: Interactive auth recovery required",
) as Error & { data?: { cause?: unknown } };
wrapper.data = { cause: nested };

expect(findNestedAuthError(wrapper)).toBe(nested);
});

it("recovers an AuthChallengeError from `data.cause` (direct transport, intercepted 401)", () => {
const nested = new AuthChallengeError({ reason: "token_expired" }, 401);
const wrapper = new Error("Version negotiation probe failed") as Error & {
data?: { cause?: unknown };
};
wrapper.data = { cause: nested };

expect(findNestedAuthError(wrapper)).toBe(nested);
});

it("follows the native `cause` link", () => {
const nested = recoveryRequired();
expect(findNestedAuthError(new Error("outer", { cause: nested }))).toBe(
nested,
);
});

it("walks more than one level and prefers the native `cause` branch", () => {
const nested = recoveryRequired();
const middle = new Error("middle") as Error & {
data?: { cause?: unknown };
};
middle.data = { cause: nested };

expect(findNestedAuthError(new Error("outer", { cause: middle }))).toBe(
nested,
);
});

it("returns the error itself when it is already a typed auth error", () => {
const err = recoveryRequired();
expect(findNestedAuthError(err)).toBe(err);
});

it("returns undefined when no auth error is in the chain", () => {
const plain = new Error("outer", { cause: new Error("inner") }) as Error & {
data?: { cause?: unknown };
};
plain.data = { cause: new Error("also not auth") };

expect(findNestedAuthError(plain)).toBeUndefined();
});

it("returns undefined for non-object errors and a non-object `data`", () => {
expect(findNestedAuthError(undefined)).toBeUndefined();
expect(findNestedAuthError(null)).toBeUndefined();
expect(findNestedAuthError("failed (401)")).toBeUndefined();
const stringData = new Error("outer") as Error & { data?: unknown };
stringData.data = "not an object";
expect(findNestedAuthError(stringData)).toBeUndefined();
const nullData = new Error("outer") as Error & { data?: unknown };
nullData.data = null;
expect(findNestedAuthError(nullData)).toBeUndefined();
});

it("terminates on a cyclic cause chain", () => {
const a = new Error("a") as Error & { cause?: unknown };
const b = new Error("b") as Error & { cause?: unknown };
a.cause = b;
b.cause = a;

expect(findNestedAuthError(a)).toBeUndefined();
});
});
143 changes: 143 additions & 0 deletions clients/web/src/test/core/mcp/inspectorClient-era-probe-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import {
AuthChallengeError,
AuthRecoveryRequiredError,
} from "@inspector/core/auth/challenge.js";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";

/**
* Connecting with `protocolEra: "auto" | "modern"` sends the SDK's
* `server/discover` negotiation probe first, and the probe's classifier reports
* whatever the transport threw as `SdkError(ERA_NEGOTIATION_FAILED)` with the
* original error moved to `data.cause`. That buried the auth signals every
* client's connect-error handling matches on, so an OAuth-protected server that
* authorized fine on the legacy era produced a dead-end "Version negotiation
* probe failed" instead of starting authorization (#1805).
*
* `connect()` unwraps the rejection, so these assert the *type* that reaches the
* caller. The live counterpart (a real modern server answering 401) is
* `src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts`.
*/
describe("InspectorClient connect() era-probe auth unwrapping (#1805)", () => {
/**
* Minimal transport whose `send` rejects — which is what the probe's
* `server/discover` exchange hits. The remote path rejects with
* `AuthRecoveryRequiredError` (after the backend intercepted the 401 and
* `handleAuthChallenge` returned `interactive`); a direct transport with
* challenge interception rejects with `AuthChallengeError`.
*/
class RejectingTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;

private readonly rejection: Error;

// A parameter property would trip `erasableSyntaxOnly`.
constructor(rejection: Error) {
this.rejection = rejection;
}

async start(): Promise<void> {}

async send(): Promise<void> {
throw this.rejection;
}

async close(): Promise<void> {
this.onclose?.();
}
}

function makeClient(
rejection: Error,
era: "legacy" | "auto" | "modern",
): InspectorClient {
return new InspectorClient(
{ type: "streamable-http", url: "https://mcp.example/mcp" },
{
environment: {
transport: () => ({ transport: new RejectingTransport(rejection) }),
},
versionNegotiation: eraToVersionNegotiation(era),
},
);
}

const recoveryRequired = () =>
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "unauthorized",
});

for (const era of ["auto", "modern"] as const) {
it(`surfaces AuthRecoveryRequiredError from the probe wrapper on the "${era}" era`, async () => {
const rejection = recoveryRequired();
const client = makeClient(rejection, era);

await expect(client.connect()).rejects.toBe(rejection);
});

it(`surfaces AuthChallengeError from the probe wrapper on the "${era}" era`, async () => {
const rejection = new AuthChallengeError(
{ reason: "token_expired" },
401,
);
const client = makeClient(rejection, era);

// The direct-recovery retry is off for this client, so the challenge
// itself reaches the caller rather than a recovery outcome.
await expect(client.connect()).rejects.toBe(rejection);
});

it(`leaves a non-auth probe failure untouched on the "${era}" era`, async () => {
const client = makeClient(new Error("ECONNREFUSED"), era);

// No auth error in the chain: the SDK's typed negotiation error stands, so
// callers still report a plain connection failure.
await expect(client.connect()).rejects.toThrow(
/Version negotiation|ECONNREFUSED/,
);
});
}

it("passes an unwrapped legacy-era rejection through unchanged", async () => {
// Legacy sends no probe, so nothing wraps the error — the baseline the
// probing eras now match.
const rejection = recoveryRequired();
const client = makeClient(rejection, "legacy");

await expect(client.connect()).rejects.toBe(rejection);
});
});

describe("InspectorClient probesProtocolEra (#1805)", () => {
function probesFor(
versionNegotiation:
| { mode?: "legacy" | "auto" | { pin: string } }
| undefined,
): boolean {
const client = new InspectorClient(
{ type: "streamable-http", url: "https://mcp.example/mcp" },
{
environment: { transport: () => ({}) as never },
...(versionNegotiation ? { versionNegotiation } : {}),
},
);
return (
client as unknown as { probesProtocolEra: () => boolean }
).probesProtocolEra();
}

it("is true for the probing eras and false for legacy", () => {
expect(probesFor(eraToVersionNegotiation("auto"))).toBe(true);
expect(probesFor(eraToVersionNegotiation("modern"))).toBe(true);
expect(probesFor(eraToVersionNegotiation("legacy"))).toBe(false);
});

it("treats an absent mode and an absent option as legacy (the SDK default)", () => {
expect(probesFor({})).toBe(false);
expect(probesFor(undefined)).toBe(false);
});
});
Loading