Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1

# Conflicts:
#	AGENTS.md
#	clients/cli/README.md
#	clients/web/src/App.test.tsx
#	clients/web/src/App.tsx
#	clients/web/src/test/core/auth/utils.test.ts
#	core/mcp/node/transport.ts
#	test-servers/src/composable-test-server.ts
This commit is contained in:
cliffhall 2026-07-03 17:39:07 -04:00
commit cfbdfdf2bd
26 changed files with 15921 additions and 1202 deletions

View file

@ -0,0 +1,393 @@
import { describe, it, expect } from "vitest";
import {
AuthChallengeError,
AuthRecoveryRequiredError,
isAuthChallengeError,
isConnectAuthRecoveryError,
parseAuthChallengeFromError,
parseAuthChallengeFromResponse,
parseScopeString,
parseWwwAuthenticateBearer,
unionAuthorizationScopes,
} from "@inspector/core/auth/challenge.js";
describe("parseWwwAuthenticateBearer", () => {
it("parses insufficient_scope and scope parameters", () => {
expect(
parseWwwAuthenticateBearer(
'Bearer error="insufficient_scope", scope="weather:read admin:write"',
),
).toEqual({
error: "insufficient_scope",
scope: "weather:read admin:write",
resourceMetadata: undefined,
errorDescription: undefined,
});
});
it("parses invalid_token and error_description", () => {
expect(
parseWwwAuthenticateBearer(
'Bearer error="invalid_token", error_description="Token expired"',
),
).toEqual({
error: "invalid_token",
scope: undefined,
resourceMetadata: undefined,
errorDescription: "Token expired",
});
});
it("parses unquoted RFC 6750 parameters", () => {
expect(
parseWwwAuthenticateBearer(
"Bearer error=insufficient_scope, scope=weather:read",
),
).toEqual({
error: "insufficient_scope",
scope: "weather:read",
resourceMetadata: undefined,
errorDescription: undefined,
});
});
it("returns empty object for non-Bearer challenges", () => {
expect(parseWwwAuthenticateBearer('Basic realm="test"')).toEqual({});
});
});
describe("parseScopeString", () => {
it("splits space-separated scopes", () => {
expect(parseScopeString("mcp tools:read")).toEqual(["mcp", "tools:read"]);
});
it("returns empty array for blank input", () => {
expect(parseScopeString(undefined)).toEqual([]);
expect(parseScopeString(" ")).toEqual([]);
});
});
describe("unionAuthorizationScopes", () => {
it("unions previous and required scopes without duplicates", () => {
expect(
unionAuthorizationScopes("mcp tools:read", [
"tools:read",
"weather:read",
]),
).toEqual(["mcp", "tools:read", "weather:read"]);
});
it("returns required scopes when no previous scope exists", () => {
expect(unionAuthorizationScopes(undefined, ["weather:read"])).toEqual([
"weather:read",
]);
});
});
describe("parseAuthChallengeFromResponse", () => {
it("maps 401 invalid_token to invalid_token reason", () => {
const response = new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": 'Bearer error="invalid_token"',
},
});
expect(parseAuthChallengeFromResponse(response)).toEqual({
reason: "invalid_token",
raw: {
httpStatus: 401,
wwwAuthenticate: 'Bearer error="invalid_token"',
},
});
});
it("maps 401 without error to token_expired", () => {
const response = new Response(null, { status: 401 });
expect(parseAuthChallengeFromResponse(response)?.reason).toBe(
"token_expired",
);
});
it("maps 401 insufficient_scope to insufficient_scope", () => {
const response = new Response(null, {
status: 401,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
});
expect(parseAuthChallengeFromResponse(response)).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
});
});
it("maps 403 insufficient_scope with required scopes", () => {
const response = new Response(null, {
status: 403,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
});
expect(
parseAuthChallengeFromResponse(response, { toolName: "get_temp" }),
).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
context: { toolName: "get_temp" },
});
});
it("returns undefined for non-auth statuses", () => {
const response = new Response(null, { status: 500 });
expect(parseAuthChallengeFromResponse(response)).toBeUndefined();
});
it("carries error_description into the challenge message", () => {
const response = new Response(null, {
status: 401,
headers: {
"WWW-Authenticate":
'Bearer error="invalid_token", error_description="Token expired"',
},
});
expect(parseAuthChallengeFromResponse(response)).toEqual({
reason: "invalid_token",
message: "Token expired",
raw: {
httpStatus: 401,
wwwAuthenticate:
'Bearer error="invalid_token", error_description="Token expired"',
},
});
});
it("maps 403 with a non-scope error to unauthorized", () => {
const response = new Response(null, {
status: 403,
headers: {
"WWW-Authenticate": 'Bearer error="invalid_token"',
},
});
expect(parseAuthChallengeFromResponse(response)?.reason).toBe(
"unauthorized",
);
});
});
describe("parseAuthChallengeFromError", () => {
it("extracts embedded authChallenge objects", () => {
const challenge = {
reason: "token_expired" as const,
};
expect(parseAuthChallengeFromError({ authChallenge: challenge })).toEqual(
challenge,
);
});
it("builds a challenge from status and WWW-Authenticate on errors", () => {
expect(
parseAuthChallengeFromError({
status: 403,
wwwAuthenticate:
'Bearer error="insufficient_scope", scope="admin:write"',
}),
).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["admin:write"],
});
});
it("returns undefined for bare 401 without auth markers", () => {
expect(parseAuthChallengeFromError({ status: 401 })).toBeUndefined();
});
it("returns the challenge directly for AuthChallengeError instances", () => {
const err = new AuthChallengeError({ reason: "invalid_token" }, 401);
expect(parseAuthChallengeFromError(err)).toEqual({
reason: "invalid_token",
});
});
it("returns undefined for non-object and null errors", () => {
expect(parseAuthChallengeFromError("boom")).toBeUndefined();
expect(parseAuthChallengeFromError(null)).toBeUndefined();
});
it("merges context into an embedded authChallenge", () => {
expect(
parseAuthChallengeFromError(
{
authChallenge: { reason: "token_expired", context: { method: "x" } },
},
{ toolName: "get_temp" },
),
).toEqual({
reason: "token_expired",
context: { method: "x", toolName: "get_temp" },
});
});
it("falls back to the numeric code when status is absent", () => {
expect(
parseAuthChallengeFromError({
code: 403,
wwwAuthenticate: 'Bearer error="insufficient_scope", scope="admin"',
}),
).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["admin"],
raw: { httpStatus: 403 },
});
});
it("returns undefined when the status is neither 401 nor 403", () => {
expect(parseAuthChallengeFromError({ status: 500 })).toBeUndefined();
});
it("reads WWW-Authenticate from a headers.get accessor", () => {
expect(
parseAuthChallengeFromError({
status: 401,
headers: {
get: (name: string) =>
name === "WWW-Authenticate" ? 'Bearer error="invalid_token"' : null,
},
}),
).toMatchObject({
reason: "invalid_token",
raw: { httpStatus: 401, wwwAuthenticate: 'Bearer error="invalid_token"' },
});
});
it("reads WWW-Authenticate from an embedded raw challenge", () => {
expect(
parseAuthChallengeFromError({
status: 401,
authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } },
}),
).toMatchObject({
reason: "token_expired",
raw: { httpStatus: 401, wwwAuthenticate: "Bearer realm=mcp" },
});
});
it("returns undefined for an empty WWW-Authenticate header", () => {
expect(
parseAuthChallengeFromError({ status: 401, wwwAuthenticate: "" }),
).toBeUndefined();
});
});
describe("isAuthChallengeError", () => {
it("detects AuthChallengeError instances", () => {
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
expect(isAuthChallengeError(err)).toBe(true);
});
it("detects 401 and 403 with WWW-Authenticate as auth challenges", () => {
expect(
isAuthChallengeError({
status: 401,
wwwAuthenticate: 'Bearer error="invalid_token"',
}),
).toBe(true);
expect(
isAuthChallengeError({
status: 403,
wwwAuthenticate: 'Bearer error="insufficient_scope"',
}),
).toBe(true);
expect(isAuthChallengeError({ status: 500 })).toBe(false);
});
it("does not treat bare 401/403 status without auth markers as auth challenge", () => {
expect(isAuthChallengeError({ status: 401 })).toBe(false);
expect(isAuthChallengeError({ status: 403 })).toBe(false);
});
it("does not treat connect-time unauthorized wording as auth challenge", () => {
expect(isAuthChallengeError(new Error("network failed"))).toBe(false);
});
it("returns false for non-object and null errors", () => {
expect(isAuthChallengeError("boom")).toBe(false);
expect(isAuthChallengeError(null)).toBe(false);
});
it("detects an embedded authChallenge with a reason", () => {
expect(
isAuthChallengeError({ authChallenge: { reason: "token_expired" } }),
).toBe(true);
});
it("uses the numeric code when status is absent", () => {
expect(
isAuthChallengeError({
code: 403,
wwwAuthenticate: 'Bearer error="insufficient_scope"',
}),
).toBe(true);
});
it("reads WWW-Authenticate from a headers.get accessor", () => {
expect(
isAuthChallengeError({
status: 401,
headers: {
get: (name: string) =>
name === "WWW-Authenticate" ? "Bearer realm=mcp" : null,
},
}),
).toBe(true);
});
it("reads WWW-Authenticate from an embedded raw challenge", () => {
expect(
isAuthChallengeError({
status: 401,
authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } },
}),
).toBe(true);
});
it("returns false for an empty WWW-Authenticate header", () => {
expect(isAuthChallengeError({ status: 401, wwwAuthenticate: "" })).toBe(
false,
);
});
});
describe("isConnectAuthRecoveryError", () => {
it("treats AuthRecoveryRequiredError and 401 connect failures as recoverable", () => {
expect(
isConnectAuthRecoveryError(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "unauthorized",
}),
),
).toBe(true);
const unauthorized = new Error("Unauthorized") as Error & {
status?: number;
};
unauthorized.status = 401;
expect(isConnectAuthRecoveryError(unauthorized)).toBe(true);
});
it("does not treat other handshake failures as recoverable", () => {
expect(isConnectAuthRecoveryError(new Error("Connection timed out"))).toBe(
false,
);
expect(
isConnectAuthRecoveryError(
new AuthChallengeError({ reason: "token_expired" }, 403),
),
).toBe(false);
});
});

View file

@ -0,0 +1,315 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
const {
sdkAuth,
discoverOAuthServerInfo,
startAuthorization,
selectResourceURL,
registerClient,
isHttpsUrl,
} = vi.hoisted(() => ({
sdkAuth: vi.fn(),
discoverOAuthServerInfo: vi.fn(),
startAuthorization: vi.fn(),
selectResourceURL: vi.fn(),
registerClient: vi.fn(),
isHttpsUrl: vi.fn(),
}));
vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@modelcontextprotocol/sdk/client/auth.js")
>();
return {
...actual,
auth: sdkAuth,
discoverOAuthServerInfo,
selectResourceURL,
startAuthorization,
registerClient,
isHttpsUrl,
};
});
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
function makeProvider(
overrides: Partial<OAuthClientProvider> = {},
): OAuthClientProvider {
return {
redirectUrl: "http://localhost/callback",
clientMetadata: {
redirect_uris: ["http://localhost/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "test",
client_uri: "https://example.com",
scope: "",
},
clientInformation: vi.fn().mockResolvedValue({ client_id: "cid" }),
tokens: vi.fn(),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn().mockReturnValue("verifier"),
...overrides,
};
}
/**
* Arrange the discovery + authorization mocks used by the
* `forceReauthorization` (authorize-without-refresh) path.
*/
function stubForcePath(
discovery: Partial<{
authorizationServerUrl: string;
authorizationServerMetadata: Record<string, unknown>;
resourceMetadata: unknown;
}> = {},
): void {
discoverOAuthServerInfo.mockResolvedValue({
authorizationServerUrl: "https://as.example.com",
authorizationServerMetadata: { issuer: "https://as.example.com" },
resourceMetadata: undefined,
...discovery,
});
selectResourceURL.mockResolvedValue(undefined);
startAuthorization.mockResolvedValue({
authorizationUrl: new URL("https://as.example.com/authorize"),
codeVerifier: "cv",
});
}
describe("mcpAuth", () => {
beforeEach(() => {
vi.clearAllMocks();
isHttpsUrl.mockReturnValue(true);
});
it("delegates to SDK auth() when forceReauthorization is not set", async () => {
sdkAuth.mockResolvedValue("AUTHORIZED");
const provider = makeProvider();
const result = await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
authorizationCode: "code",
fetchFn: fetch,
});
expect(result).toBe("AUTHORIZED");
expect(sdkAuth).toHaveBeenCalledWith(provider, {
serverUrl: "https://mcp.example.com",
authorizationCode: "code",
scope: undefined,
resourceMetadataUrl: undefined,
fetchFn: fetch,
});
expect(discoverOAuthServerInfo).not.toHaveBeenCalled();
});
it("rejects forceReauthorization combined with authorizationCode", async () => {
const provider = makeProvider();
await expect(
mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
authorizationCode: "code",
forceReauthorization: true,
}),
).rejects.toThrow(
"forceReauthorization cannot be combined with authorizationCode",
);
expect(sdkAuth).not.toHaveBeenCalled();
expect(discoverOAuthServerInfo).not.toHaveBeenCalled();
});
it("uses discovery + startAuthorization when forceReauthorization is true", async () => {
stubForcePath();
const provider = makeProvider();
const result = await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
scope: "mcp weather:read",
forceReauthorization: true,
fetchFn: fetch,
});
expect(result).toBe("REDIRECT");
expect(sdkAuth).not.toHaveBeenCalled();
expect(startAuthorization).toHaveBeenCalled();
expect(provider.saveCodeVerifier).toHaveBeenCalledWith("cv");
expect(provider.redirectToAuthorization).toHaveBeenCalled();
});
it("persists discovery state and forwards the selected resource + state", async () => {
stubForcePath({
resourceMetadata: { resource: "https://mcp.example.com" },
});
selectResourceURL.mockResolvedValue(new URL("https://mcp.example.com/r"));
const saveDiscoveryState = vi.fn().mockResolvedValue(undefined);
const state = vi.fn().mockResolvedValue("state-123");
const provider = makeProvider({ saveDiscoveryState, state });
await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
resourceMetadataUrl: new URL("https://mcp.example.com/.well-known/x"),
forceReauthorization: true,
});
expect(saveDiscoveryState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationServerUrl: "https://as.example.com",
resourceMetadataUrl: "https://mcp.example.com/.well-known/x",
}),
);
expect(state).toHaveBeenCalled();
expect(startAuthorization).toHaveBeenCalledWith(
"https://as.example.com",
expect.objectContaining({
state: "state-123",
resource: new URL("https://mcp.example.com/r"),
}),
);
});
it("derives scope from resourceMetadata.scopes_supported when no scope given", async () => {
stubForcePath({
resourceMetadata: { scopes_supported: ["mcp", "weather:read"] },
});
const provider = makeProvider();
await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
forceReauthorization: true,
});
expect(startAuthorization).toHaveBeenCalledWith(
"https://as.example.com",
expect.objectContaining({ scope: "mcp weather:read" }),
);
});
it("falls back to clientMetadata.scope when nothing else provides scope", async () => {
stubForcePath();
const provider = makeProvider({
clientMetadata: {
...makeProvider().clientMetadata,
scope: "fallback:scope",
},
});
await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
forceReauthorization: true,
});
expect(startAuthorization).toHaveBeenCalledWith(
"https://as.example.com",
expect.objectContaining({ scope: "fallback:scope" }),
);
});
it("dynamically registers a client when no client information exists", async () => {
stubForcePath();
registerClient.mockResolvedValue({ client_id: "registered" });
const saveClientInformation = vi.fn().mockResolvedValue(undefined);
const provider = makeProvider({
clientInformation: vi.fn().mockResolvedValue(undefined),
saveClientInformation,
});
const result = await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
scope: "mcp",
forceReauthorization: true,
});
expect(result).toBe("REDIRECT");
expect(registerClient).toHaveBeenCalledWith(
"https://as.example.com",
expect.objectContaining({ scope: "mcp" }),
);
expect(saveClientInformation).toHaveBeenCalledWith({
client_id: "registered",
});
});
it("uses a URL-based client id when the AS supports it", async () => {
stubForcePath({
authorizationServerMetadata: {
issuer: "https://as.example.com",
client_id_metadata_document_supported: true,
},
});
isHttpsUrl.mockReturnValue(true);
const saveClientInformation = vi.fn().mockResolvedValue(undefined);
const provider = makeProvider({
clientInformation: vi.fn().mockResolvedValue(undefined),
clientMetadataUrl: "https://app.example.com/client.json",
saveClientInformation,
});
await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
forceReauthorization: true,
});
expect(registerClient).not.toHaveBeenCalled();
expect(saveClientInformation).toHaveBeenCalledWith({
client_id: "https://app.example.com/client.json",
});
});
it("rejects a non-HTTPS clientMetadataUrl", async () => {
stubForcePath();
isHttpsUrl.mockReturnValue(false);
const provider = makeProvider({
clientInformation: vi.fn().mockResolvedValue(undefined),
clientMetadataUrl: "http://insecure.example.com/client.json",
saveClientInformation: vi.fn(),
});
await expect(
mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
forceReauthorization: true,
}),
).rejects.toBeInstanceOf(InvalidClientMetadataError);
expect(registerClient).not.toHaveBeenCalled();
});
it("requires saveClientInformation for dynamic registration", async () => {
stubForcePath();
const provider = makeProvider({
clientInformation: vi.fn().mockResolvedValue(undefined),
saveClientInformation: undefined,
});
await expect(
mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
forceReauthorization: true,
}),
).rejects.toThrow(
"OAuth client information must be saveable for dynamic registration",
);
expect(registerClient).not.toHaveBeenCalled();
});
it("requires a redirectUrl for the authorization_code flow", async () => {
stubForcePath();
const provider = makeProvider({ redirectUrl: undefined });
await expect(
mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
forceReauthorization: true,
}),
).rejects.toThrow("redirectUrl is required for authorization_code flow");
expect(startAuthorization).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,336 @@
import { describe, it, expect } from "vitest";
import {
authRecoveryRestoredMessage,
emaStepUpFailureMessage,
emaStepUpInProgressMessage,
emaStepUpSuccessMessage,
isActionTriggeredOAuthRecovery,
isEmaStepUp,
isReAuthBannerReason,
isStandardOAuthStepUp,
isStepUpConfirmation,
oauthPreRedirectToastCopy,
oauthResumeAbandonedMessage,
oauthResumeSuccessMessage,
reAuthBannerMessage,
stepUpAdditionalScopes,
stepUpConfirmMessage,
stepUpFollowUpMessage,
stepUpInsufficientScopeMessage,
stepUpModalTitle,
stepUpAuthorizeActionLabel,
} from "@inspector/core/auth/oauthUx.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
describe("oauthUx step-up copy", () => {
const challenge: AuthChallenge = {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
authorizationScopes: ["mcp", "tools:read", "weather:read"],
context: { toolName: "get_temp" },
};
it("stepUpConfirmMessage prefers tool context over scope union", () => {
expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/);
expect(stepUpConfirmMessage(challenge)).not.toMatch(/tools:read/);
});
it("stepUpConfirmMessage lists only requiredScopes when no tool context", () => {
expect(
stepUpConfirmMessage({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
authorizationScopes: ["mcp", "tools:read", "weather:read"],
}),
).toBe("This operation needs additional scope: weather:read.");
});
it("stepUpConfirmMessage uses plural label and organization language for multiple EMA scopes", () => {
expect(
stepUpConfirmMessage(
{
reason: "insufficient_scope",
requiredScopes: ["weather:read", "weather:write"],
},
{ enterpriseManaged: true },
),
).toBe(
"This operation needs additional organization scopes: weather:read, weather:write.",
);
});
it("stepUpConfirmMessage uses plural label for multiple standard scopes", () => {
expect(
stepUpConfirmMessage({
reason: "insufficient_scope",
requiredScopes: ["weather:read", "weather:write"],
}),
).toBe(
"This operation needs additional scopes: weather:read, weather:write.",
);
});
it("stepUpConfirmMessage falls back to generic standard copy with no tool or scopes", () => {
expect(stepUpConfirmMessage({ reason: "insufficient_scope" })).toBe(
"This operation needs additional OAuth scopes before it can continue.",
);
});
it("stepUpConfirmMessage falls back to generic EMA copy with no tool or scopes", () => {
expect(
stepUpConfirmMessage(
{ reason: "insufficient_scope", requiredScopes: ["", ""] },
{ enterpriseManaged: true },
),
).toBe(
"This operation needs additional permissions from your organization before it can continue.",
);
});
it("stepUpConfirmMessage uses organization language for EMA", () => {
expect(
stepUpConfirmMessage(challenge, { enterpriseManaged: true }),
).toMatch(/organization/i);
expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch(
/identity provider/i,
);
expect(stepUpFollowUpMessage()).toMatch(/redirected to authorize/i);
expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch(
/organization/i,
);
expect(stepUpModalTitle()).toBe("Additional permissions required");
expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe(
"Authorize",
);
expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)");
});
it("isStandardOAuthStepUp is true only for non-EMA insufficient_scope", () => {
expect(isStandardOAuthStepUp(challenge)).toBe(true);
expect(isStandardOAuthStepUp(challenge, { enterpriseManaged: true })).toBe(
false,
);
expect(isStandardOAuthStepUp({ reason: "token_expired" })).toBe(false);
});
it("isStepUpConfirmation covers standard OAuth and EMA insufficient_scope", () => {
expect(isStepUpConfirmation(challenge)).toBe(true);
expect(isStepUpConfirmation(challenge, { enterpriseManaged: true })).toBe(
true,
);
expect(isEmaStepUp(challenge, { enterpriseManaged: true })).toBe(true);
expect(isEmaStepUp(challenge)).toBe(false);
expect(
isStepUpConfirmation(
{ reason: "token_expired" },
{
enterpriseManaged: true,
},
),
).toBe(false);
});
it("emaStepUpInProgressMessage describes requesting organization permissions", () => {
expect(emaStepUpInProgressMessage()).toMatch(/organization/i);
});
it("emaStepUpSuccessMessage suggests retry only for command-scoped recovery", () => {
expect(emaStepUpSuccessMessage()).toBe(
"Organization permissions were updated.",
);
expect(emaStepUpSuccessMessage({ recoverySource: "tool" })).toMatch(
/Retry your action/,
);
});
it("emaStepUpFailureMessage returns detail when present, else generic copy", () => {
expect(emaStepUpFailureMessage("boom")).toBe("boom");
expect(emaStepUpFailureMessage(" ")).toBe(
"Could not obtain the additional permissions from your organization.",
);
expect(emaStepUpFailureMessage()).toBe(
"Could not obtain the additional permissions from your organization.",
);
});
it("stepUpAdditionalScopes returns requiredScopes only", () => {
expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]);
});
it("stepUpAdditionalScopes returns empty array when requiredScopes undefined", () => {
expect(stepUpAdditionalScopes({ reason: "insufficient_scope" })).toEqual(
[],
);
});
});
describe("oauthUx recovery-source predicates", () => {
it("isActionTriggeredOAuthRecovery is true for action sources, false otherwise", () => {
for (const source of ["tool", "prompt", "resource", "app"] as const) {
expect(isActionTriggeredOAuthRecovery(source)).toBe(true);
}
expect(isActionTriggeredOAuthRecovery("ambient")).toBe(false);
expect(isActionTriggeredOAuthRecovery(undefined)).toBe(false);
});
});
describe("oauthUx resume/restore copy", () => {
it("oauthResumeSuccessMessage step_up varies with retry", () => {
expect(
oauthResumeSuccessMessage("step_up", { recoverySource: "tool" }),
).toBe("Step-up authorization succeeded. Retry your action.");
expect(oauthResumeSuccessMessage("step_up")).toBe(
"Step-up authorization succeeded.",
);
});
it("oauthResumeSuccessMessage reauth varies with retry", () => {
expect(
oauthResumeSuccessMessage("reauth", { recoverySource: "prompt" }),
).toBe("Authentication succeeded. Retry your action.");
expect(oauthResumeSuccessMessage("reauth")).toBe(
"Authentication succeeded.",
);
});
it("authRecoveryRestoredMessage varies with retry", () => {
expect(authRecoveryRestoredMessage({ recoverySource: "resource" })).toBe(
"Session credentials were updated. Retry your action.",
);
expect(authRecoveryRestoredMessage()).toBe(
"Session credentials were updated.",
);
});
it("oauthResumeAbandonedMessage reauth is retry-agnostic", () => {
expect(
oauthResumeAbandonedMessage("reauth", { recoverySource: "tool" }),
).toBe("Sign-in was not completed. Re-authenticate to restore access.");
expect(oauthResumeAbandonedMessage("reauth")).toBe(
"Sign-in was not completed. Re-authenticate to restore access.",
);
});
it("oauthResumeAbandonedMessage step_up varies with retry", () => {
expect(
oauthResumeAbandonedMessage("step_up", { recoverySource: "app" }),
).toBe("Step-up authorization was not completed. Retry your action.");
expect(oauthResumeAbandonedMessage("step_up")).toBe(
"Step-up authorization was not completed.",
);
});
});
describe("oauthUx insufficient-scope resolution copy", () => {
it("prefers tool context", () => {
expect(
stepUpInsufficientScopeMessage({
reason: "insufficient_scope",
context: { toolName: "get_temp" },
}),
).toMatch(/tool "get_temp"/);
});
it("uses authorizationScopes when present and no tool", () => {
expect(
stepUpInsufficientScopeMessage({
reason: "insufficient_scope",
authorizationScopes: ["a", "b"],
requiredScopes: ["c"],
}),
).toBe(
"Authorization completed, but required scopes were not granted (a, b). Grant the requested permissions on the authorization server, then retry your action.",
);
});
it("falls back to requiredScopes when authorizationScopes absent", () => {
expect(
stepUpInsufficientScopeMessage({
reason: "insufficient_scope",
requiredScopes: ["c"],
}),
).toMatch(/\(c\)/);
});
it("uses generic copy when no tool or scopes", () => {
expect(
stepUpInsufficientScopeMessage({ reason: "insufficient_scope" }),
).toBe(
"Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action.",
);
});
});
describe("oauthUx pre-redirect toast copy", () => {
it("returns undefined for a fresh connect handshake", () => {
expect(
oauthPreRedirectToastCopy("reauth", { context: "connect" }),
).toBeUndefined();
});
it("step_up toast includes server name when provided", () => {
expect(oauthPreRedirectToastCopy("step_up", { serverName: "svc" })).toEqual(
{
title: 'Step-up authorization for "svc"',
message: "Redirecting to authorize additional permissions…",
},
);
expect(oauthPreRedirectToastCopy("step_up", {})).toEqual({
title: "Step-up authorization",
message: "Redirecting to authorize additional permissions…",
});
});
it("enterprise-managed reauth toast re-authenticates", () => {
expect(
oauthPreRedirectToastCopy("reauth", {
serverName: "svc",
enterpriseManaged: true,
}),
).toEqual({
title: 'Re-authenticating "svc"',
message: "Re-authenticating…",
});
expect(
oauthPreRedirectToastCopy("reauth", { enterpriseManaged: true }),
).toEqual({ title: "Re-authenticating", message: "Re-authenticating…" });
});
it("default reauth toast signals an expired session", () => {
expect(oauthPreRedirectToastCopy("reauth", { serverName: "svc" })).toEqual({
title: 'Session expired for "svc"',
message: "Session expired, re-authenticating…",
});
expect(oauthPreRedirectToastCopy("reauth", {})).toEqual({
title: "Session expired",
message: "Session expired, re-authenticating…",
});
});
});
describe("oauthUx re-auth banner", () => {
it("isReAuthBannerReason is true for degraded-session reasons", () => {
for (const reason of [
"token_expired",
"unauthorized",
"invalid_token",
] as const) {
expect(isReAuthBannerReason(reason)).toBe(true);
}
expect(isReAuthBannerReason("insufficient_scope")).toBe(false);
expect(isReAuthBannerReason(undefined)).toBe(false);
});
it("reAuthBannerMessage varies with server name and detail", () => {
expect(
reAuthBannerMessage({ serverName: "svc", detail: "Token expired." }),
).toBe('Authentication for "svc" needs attention. Token expired.');
expect(reAuthBannerMessage({ serverName: "svc" })).toBe(
'Authentication for "svc" needs attention.',
);
expect(reAuthBannerMessage({ detail: "Token expired." })).toBe(
"Authentication needs attention. Token expired.",
);
expect(reAuthBannerMessage({})).toBe("Authentication needs attention.");
});
});

View file

@ -327,6 +327,23 @@ describe("OAuthNavigation", () => {
expect(navCallback).toHaveBeenCalledWith(url);
});
it("redirectToAuthorization captures URL but skips navigation when suppressed", () => {
const storage = makeStorage();
const navCallback = vi.fn();
const provider = makeProvider(storage, navCallback);
const url = new URL("https://mcp.example.com/authorize");
provider.setSuppressAuthorizationNavigation(true);
provider.redirectToAuthorization(url);
expect(provider.getCapturedAuthUrl()).toBe(url);
expect(navCallback).not.toHaveBeenCalled();
provider.setSuppressAuthorizationNavigation(false);
provider.redirectToAuthorization(url);
expect(navCallback).toHaveBeenCalledWith(url);
});
it("delegates token and scope persistence to storage", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);

View file

@ -0,0 +1,442 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
runRunnerInteractiveOAuth,
type RunnerInteractiveOAuthClient,
} from "@inspector/core/auth/node/runner-interactive-oauth.js";
import {
createOAuthCallbackServer,
type OAuthCallbackServer,
type OAuthCallbackServerStartOptions,
} from "@inspector/core/auth/node/oauth-callback-server.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
vi.mock(
"@inspector/core/auth/node/oauth-callback-server.js",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@inspector/core/auth/node/oauth-callback-server.js")
>();
return { ...actual, createOAuthCallbackServer: vi.fn() };
},
);
function mockClient(
overrides: Partial<RunnerInteractiveOAuthClient> = {},
): RunnerInteractiveOAuthClient {
return {
authenticate: vi.fn(async () => undefined),
beginInteractiveAuthorization: vi.fn(async () => {}),
completeOAuthFlow: vi.fn(async () => {}),
checkAuthChallengeSatisfied: vi.fn(async () => true),
...overrides,
};
}
interface MockCallbackHandlers {
onCallback?: OAuthCallbackServerStartOptions["onCallback"];
onError?: OAuthCallbackServerStartOptions["onError"];
}
function createMockCallbackServer(handlers: {
current: MockCallbackHandlers;
}): OAuthCallbackServer {
return {
start: vi.fn(async (opts: OAuthCallbackServerStartOptions) => {
handlers.current = {
onCallback: opts.onCallback,
onError: opts.onError,
};
return {
port: 6276,
redirectUrl: "http://127.0.0.1:6276/oauth/callback",
};
}),
stop: vi.fn(async () => {}),
} as unknown as OAuthCallbackServer;
}
/** Drive the loopback callback the way a browser redirect would. */
async function simulateCallback(
handlers: MockCallbackHandlers,
code = "auth-code-123",
): Promise<void> {
if (!handlers.onCallback) {
throw new Error("onCallback not registered");
}
await handlers.onCallback({ code });
}
describe("runRunnerInteractiveOAuth", () => {
const handlers: { current: MockCallbackHandlers } = { current: {} };
afterEach(() => {
handlers.current = {};
vi.restoreAllMocks();
});
it("returns already_authorized when authenticate yields no URL", async () => {
const client = mockClient({
authenticate: vi.fn(async () => undefined),
});
const redirectUrlProvider = { redirectUrl: "" };
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "already_authorized" });
expect(client.completeOAuthFlow).not.toHaveBeenCalled();
});
it("clears the callback timeout on already_authorized", async () => {
vi.useFakeTimers();
try {
const client = mockClient({
authenticate: vi.fn(async () => undefined),
});
const redirectUrlProvider = { redirectUrl: "" };
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
callbackTimeoutMs: 60_000,
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "already_authorized" });
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
} finally {
vi.useRealTimers();
}
});
it("completes connect-time OAuth via authenticate and callback", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "success" });
expect(client.completeOAuthFlow).toHaveBeenCalledWith("auth-code-123");
expect(redirectUrlProvider.redirectUrl).toBe(
"http://127.0.0.1:6276/oauth/callback",
);
});
it("uses beginInteractiveAuthorization when authorizationUrl is provided", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const authorizationUrl = new URL(
"https://as.example/authorize?state=step-up",
);
const client = mockClient({
beginInteractiveAuthorization: vi.fn(async () => {
await simulateCallback(handlers.current, "step-up-code");
}),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
authorizationUrl,
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "success" });
expect(client.beginInteractiveAuthorization).toHaveBeenCalledWith(
authorizationUrl,
);
expect(client.authenticate).not.toHaveBeenCalled();
expect(client.completeOAuthFlow).toHaveBeenCalledWith("step-up-code");
});
it("returns insufficient_scope when post-step-up check fails", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const challenge: AuthChallenge = {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
};
const client = mockClient({
beginInteractiveAuthorization: vi.fn(async () => {
await simulateCallback(handlers.current);
}),
checkAuthChallengeSatisfied: vi.fn(async () => false),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
authorizationUrl: new URL("https://as.example/authorize"),
authChallenge: challenge,
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "insufficient_scope", challenge });
});
it("propagates completeOAuthFlow failures", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
completeOAuthFlow: vi.fn(async () => {
throw new Error("token exchange failed");
}),
});
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
}),
).rejects.toThrow("token exchange failed");
});
it("propagates OAuth callback errors from the authorization server", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
handlers.current.onError?.({
error: "access_denied",
error_description: "user cancelled",
});
return new URL("https://as.example/authorize");
}),
});
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
}),
).rejects.toThrow("user cancelled");
});
it("times out when the browser callback never arrives", async () => {
vi.useFakeTimers();
try {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(
async () => new URL("https://as.example/authorize"),
),
});
const promise = runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
callbackTimeoutMs: 1000,
createCallbackServer: () => createMockCallbackServer(handlers),
});
const assertRejects =
expect(promise).rejects.toThrow(/timed out after 1s/);
await vi.advanceTimersByTimeAsync(1000);
await assertRejects;
} finally {
vi.useRealTimers();
}
});
it("invokes onCallbackServer with the live listener", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const onCallbackServer = vi.fn();
const mockServer = createMockCallbackServer(handlers);
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
});
await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
onCallbackServer,
createCallbackServer: () => mockServer,
});
expect(onCallbackServer).toHaveBeenCalledTimes(1);
expect(onCallbackServer).toHaveBeenCalledWith(mockServer);
});
it("wraps a non-Error completeOAuthFlow rejection", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current).catch(() => {});
return new URL("https://as.example/authorize");
}),
completeOAuthFlow: vi.fn(async () => {
throw "string failure";
}),
});
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
}),
).rejects.toThrow("string failure");
});
it("falls back to params.error when no error_description is present", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
handlers.current.onError?.({ error: "access_denied" });
return new URL("https://as.example/authorize");
}),
});
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
}),
).rejects.toThrow("access_denied");
});
it("defaults to createOAuthCallbackServer when none is provided", async () => {
const redirectUrlProvider = { redirectUrl: "" };
vi.mocked(createOAuthCallbackServer).mockReturnValue(
createMockCallbackServer(handlers),
);
const client = mockClient({
authenticate: vi.fn(async () => undefined),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
});
expect(result).toEqual({ kind: "already_authorized" });
expect(createOAuthCallbackServer).toHaveBeenCalled();
});
it("swallows a server.stop() rejection during cleanup", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const mockServer = createMockCallbackServer(handlers);
vi.mocked(mockServer.stop).mockRejectedValue(new Error("stop failed"));
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => mockServer,
});
expect(result).toEqual({ kind: "success" });
expect(mockServer.stop).toHaveBeenCalled();
});
it("cleans up when the callback server fails to start", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const mockServer = createMockCallbackServer(handlers);
vi.mocked(mockServer.start).mockRejectedValue(new Error("bind failed"));
const client = mockClient();
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => mockServer,
}),
).rejects.toThrow("bind failed");
expect(mockServer.stop).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import {
computeScopeUnion,
isStrictScopeSuperset,
resolveEffectiveGrantedScope,
resolvePersistedScopeAfterGrant,
} from "@inspector/core/auth/scopes.js";
describe("scopes", () => {
describe("computeScopeUnion", () => {
it("unions and dedupes scope tokens", () => {
expect(
computeScopeUnion("mcp tools:read", "tools:read weather:read"),
).toBe("mcp tools:read weather:read");
});
it("returns undefined when all inputs are empty", () => {
expect(computeScopeUnion(undefined, "", undefined)).toBeUndefined();
});
});
describe("isStrictScopeSuperset", () => {
it("returns true when union adds a new scope", () => {
expect(
isStrictScopeSuperset("mcp tools:read weather:read", "mcp tools:read"),
).toBe(true);
});
it("returns false when union is covered by current grant", () => {
expect(isStrictScopeSuperset("mcp tools:read", "mcp tools:read")).toBe(
false,
);
});
it("treats missing token scope as empty (forces re-auth)", () => {
expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true);
});
});
describe("resolvePersistedScopeAfterGrant", () => {
it("prefers explicit granted scope over requested", () => {
expect(resolvePersistedScopeAfterGrant("mcp", "mcp weather:read")).toBe(
"mcp",
);
});
it("falls back to requested scope when grant omits scope", () => {
expect(
resolvePersistedScopeAfterGrant(undefined, "mcp weather:read"),
).toBe("mcp weather:read");
});
});
describe("resolveEffectiveGrantedScope", () => {
it("uses token scope when present even if storage overstates grant", () => {
expect(resolveEffectiveGrantedScope("mcp weather:read", "mcp")).toBe(
"mcp",
);
});
it("falls back to stored scope when token omits scope", () => {
expect(resolveEffectiveGrantedScope("mcp tools:read", undefined)).toBe(
"mcp tools:read",
);
});
});
});

View file

@ -5,7 +5,10 @@ import {
generateOAuthState,
parseOAuthState,
generateOAuthErrorDescription,
formatOAuthFailureDetail,
isUnauthorizedError,
} from "@inspector/core/auth/utils.js";
import { z, ZodError } from "zod";
describe("parseHttpUrl", () => {
it("parses valid URLs", () => {
@ -17,6 +20,12 @@ describe("parseHttpUrl", () => {
it("throws on invalid URLs", () => {
expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/);
});
it("includes the trimmed offending value in the thrown message", () => {
expect(() => parseHttpUrl(" bad url ", "Server URL")).toThrow(
/Invalid Server URL: "bad url"/,
);
});
});
describe("parseOAuthCallbackParams", () => {
@ -129,3 +138,152 @@ describe("generateOAuthErrorDescription", () => {
);
});
});
describe("formatOAuthFailureDetail", () => {
const zodTokenJson = `[ { "expected": "string", "code": "invalid_type", "path": [ "access_token" ], "message": "Invalid input" }, { "expected": "string", "code": "invalid_type", "path": [ "token_type" ], "message": "Invalid input" } ]`;
it("replaces serialized Zod token-response issues with readable copy", () => {
expect(formatOAuthFailureDetail(zodTokenJson)).toBe(
"The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again.",
);
});
it("formats ZodError instances", () => {
const err = new ZodError([
{
code: "invalid_type",
expected: "string",
path: ["access_token"],
message: "Invalid input",
},
]);
expect(formatOAuthFailureDetail(err)).toMatch(/valid tokens/i);
});
it("passes through ordinary error messages", () => {
expect(formatOAuthFailureDetail(new Error("Network timeout"))).toBe(
"Network timeout",
);
});
it("passes through plain strings that are not bracketed JSON", () => {
expect(formatOAuthFailureDetail("something went wrong")).toBe(
"something went wrong",
);
});
it("stringifies non-string/non-error values", () => {
expect(formatOAuthFailureDetail(42)).toBe("42");
expect(formatOAuthFailureDetail(null)).toBe("null");
});
it("formats a real ZodError instance (non-token path)", () => {
const err = z.string().safeParse(123).error!;
const result = formatOAuthFailureDetail(err);
expect(result).not.toMatch(/valid tokens/i);
// Root-level failure has an empty path → "input" label
expect(result).toMatch(/^input: /);
});
it("joins non-token issues as `path: message`", () => {
const err = new ZodError([
{
code: "invalid_type",
expected: "string",
path: ["client", "id"],
message: "Required",
},
]);
expect(formatOAuthFailureDetail(err)).toBe("client.id: Required");
});
it("labels an issue with an empty path as `input` and defaults a missing message to `invalid`", () => {
const err = new ZodError([
{
code: "custom",
path: [],
} as unknown as z.core.$ZodIssue,
]);
expect(formatOAuthFailureDetail(err)).toBe("input: invalid");
});
it("formats a bracketed JSON string of non-token zod issues", () => {
const json = `[ { "code": "invalid_type", "path": [ "scope" ], "message": "Required" } ]`;
expect(formatOAuthFailureDetail(json)).toBe("scope: Required");
});
it("returns the raw string when bracketed JSON is invalid", () => {
const bad = "[ not valid json";
expect(formatOAuthFailureDetail(bad)).toBe(bad);
});
it("returns the raw string when bracketed JSON is not a zod-issue array", () => {
// Parses fine, but empty array / first element lacks `code`
expect(formatOAuthFailureDetail("[]")).toBe("[]");
const notIssues = `[ { "foo": "bar" } ]`;
expect(formatOAuthFailureDetail(notIssues)).toBe(notIssues);
});
it("returns the raw string when bracketed JSON is a non-array value", () => {
const obj = `[1, 2, 3]`;
// Array of numbers: first element is not an object → not a zod-issue array
expect(formatOAuthFailureDetail(obj)).toBe(obj);
});
});
describe("isUnauthorizedError", () => {
it("returns true for an object with status 401", () => {
expect(isUnauthorizedError({ status: 401 })).toBe(true);
});
it("returns true for an object with code 401", () => {
expect(isUnauthorizedError({ code: 401 })).toBe(true);
});
it("returns true when an Error message matches the transport wording", () => {
expect(
isUnauthorizedError(new Error("Connection failed for server (401)")),
).toBe(true);
});
it("returns true when a non-error value stringifies to matching wording", () => {
expect(isUnauthorizedError("request failed with status (401)")).toBe(true);
});
it("returns false for unrelated (401) mentions without `failed`", () => {
expect(isUnauthorizedError(new Error("error code (401) noted"))).toBe(
false,
);
});
it("returns false for a non-401 object", () => {
expect(isUnauthorizedError({ status: 500 })).toBe(false);
});
it("returns false for null", () => {
expect(isUnauthorizedError(null)).toBe(false);
});
});
describe("generateOAuthErrorDescription without description/uri", () => {
it("omits the details and more-info lines when absent", () => {
const message = generateOAuthErrorDescription({
successful: false,
error: "server_error",
error_description: null,
error_uri: null,
});
expect(message).toBe("Error: server_error.");
});
});
describe("parseOAuthCallbackParams error without description", () => {
it("returns null description/uri when only error is present", () => {
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
successful: false,
error: "access_denied",
error_description: null,
error_uri: null,
});
});
});

View file

@ -0,0 +1,288 @@
/**
* Remote transport contract: HTTP ack + SSE payload, auth retry on send.
*/
import { describe, it, expect, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
import { MessageTrackingTransport } from "@inspector/core/mcp/messageTrackingTransport.js";
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
const config = {
type: "streamable-http" as const,
url: "http://localhost/mcp",
};
/** SSE stream that can push MCP message events after /api/mcp/send accepts a request. */
function createPushableSseStream() {
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
c.enqueue(encoder.encode(": keepalive\n\n"));
},
});
const pushMessage = (message: unknown) => {
const payload = JSON.stringify({ type: "message", data: message });
controller?.enqueue(encoder.encode(`data: ${payload}\n\n`));
};
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
pushMessage,
};
}
describe("RemoteClientTransport send contract", () => {
it("waits for SSE response after HTTP ok:true and completes within 1s", async () => {
const sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
return new Response(JSON.stringify({ sessionId: "s1" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
return sse.response;
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
const body = JSON.parse(String(init.body)) as {
message: { id?: string | number; method?: string };
};
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: { tools: [] },
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
},
config,
);
await transport.start();
const started = Date.now();
await transport.send({ jsonrpc: "2.0", id: 7, method: "tools/list" });
expect(Date.now() - started).toBeLessThan(1000);
await transport.close();
});
it("times out within 5s when HTTP ok:true but no SSE response arrives", async () => {
const sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
return new Response(JSON.stringify({ sessionId: "s1" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
return sse.response;
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 500,
},
config,
);
await transport.start();
const started = Date.now();
await expect(
transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
).rejects.toThrow(/Timed out waiting for MCP response on SSE/i);
expect(Date.now() - started).toBeLessThan(5000);
await transport.close();
});
it("retries send once after satisfied auth challenge and pushAuthState", async () => {
const sse = createPushableSseStream();
let sendCalls = 0;
let connectCalls = 0;
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
connectCalls += 1;
return new Response(
JSON.stringify({ sessionId: `s${connectCalls}` }),
{ status: 200 },
);
}
if (url.includes("/api/mcp/events")) {
return sse.response;
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
sendCalls += 1;
const body = JSON.parse(String(init.body)) as {
message: { id?: string | number };
};
if (sendCalls === 1) {
return new Response(
JSON.stringify({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
{ status: 200 },
);
}
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: { tools: [{ name: "echo" }] },
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const pushAuthState = vi.fn().mockResolvedValue(undefined);
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
authRecovery: {
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
pushAuthState,
},
},
config,
);
await transport.start();
const started = Date.now();
await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" });
expect(Date.now() - started).toBeLessThan(5000);
expect(sendCalls).toBe(2);
expect(connectCalls).toBe(1);
expect(pushAuthState).toHaveBeenCalledTimes(1);
await transport.close();
});
it("SDK listTools succeeds through full stack after auth retry", async () => {
let sse = createPushableSseStream();
let sendCalls = 0;
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
return new Response(JSON.stringify({ sessionId: "s1" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
sse = createPushableSseStream();
return sse.response;
}
if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
sendCalls += 1;
const body = JSON.parse(String(init.body)) as {
message: { method?: string; id?: string | number };
};
if (body.message.method === "initialize") {
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "test", version: "1.0.0" },
},
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (body.message.method === "tools/list") {
if (sendCalls === 2) {
return new Response(
JSON.stringify({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
{ status: 200 },
);
}
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: {
tools: [{ name: "echo", inputSchema: { type: "object" } }],
},
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const authProvider = {
tokens: vi.fn().mockResolvedValue({
access_token: "refreshed",
token_type: "Bearer",
}),
};
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
authProvider: authProvider as never,
},
config,
);
transport.setAuthRecovery({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
pushAuthState: () => transport.pushAuthState(),
});
const wrapped = new MessageTrackingTransport(transport, {});
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(wrapped);
const started = Date.now();
const result = await client.listTools();
expect(Date.now() - started).toBeLessThan(5000);
expect(result.tools).toHaveLength(1);
await transport.close();
});
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
describe("InspectorClient ambient auth dedup", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("joins concurrent handleAmbientAuthChallenge calls for the same challenge", async () => {
const client = Object.create(InspectorClient.prototype) as InspectorClient;
const handleAuthChallenge = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ kind: "satisfied" as const }), 20);
}),
);
(client as unknown as { oauthManager: unknown }).oauthManager = {
handleAuthChallenge,
};
(
client as unknown as {
ambientAuthChallengeInFlight: Map<string, Promise<void>>;
}
).ambientAuthChallengeInFlight = new Map();
(client as unknown as { baseTransport: unknown }).baseTransport =
Object.create(RemoteClientTransport.prototype);
vi.spyOn(client, "pushRemoteAuthState").mockResolvedValue(undefined);
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
const challenge = {
reason: "token_expired" as const,
};
await Promise.all([
client.handleAmbientAuthChallenge(challenge),
client.handleAmbientAuthChallenge(challenge),
]);
expect(handleAuthChallenge).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,297 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
/**
* Additional branch coverage for the mid-session auth recovery paths
* (`handleAmbientAuthChallenge` / `runAmbientAuthChallenge`, `withDirectAuthRecovery`,
* `pushRemoteAuthState`, `resumeAfterOAuth`). Complements
* inspectorClient-ambient-auth.test.ts and inspectorClient-direct-auth-recovery.test.ts,
* which each cover a single "happy path" through these methods.
*
* Uses the same `Object.create(InspectorClient.prototype)` technique as the
* sibling test files so each test can wire up only the private fields its
* branch needs, without a live transport or a real OAuthManager.
*/
type FakeOAuthManager = {
handleAuthChallenge: ReturnType<typeof vi.fn>;
completeOAuthFlow?: ReturnType<typeof vi.fn>;
};
type Internals = {
oauthManager: FakeOAuthManager | null;
baseTransport: unknown;
ambientAuthChallengeInFlight: Map<string, Promise<void>>;
directAuthRecovery: boolean;
directAuthRecoveryActive: boolean | null;
activeToolCallAbortController?: AbortController;
status: string;
reconnectAfterAuthRecovery: () => Promise<void>;
withDirectAuthRecovery: <T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
attempt?: number,
) => Promise<T>;
};
function makeClient(): InspectorClient {
const client = Object.create(InspectorClient.prototype) as InspectorClient;
(client as unknown as Internals).ambientAuthChallengeInFlight = new Map();
return client;
}
function internalsOf(client: InspectorClient): Internals {
return client as unknown as Internals;
}
describe("InspectorClient pushRemoteAuthState", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("is a no-op when the base transport is not a RemoteClientTransport", async () => {
const client = makeClient();
internalsOf(client).baseTransport = null;
await expect(client.pushRemoteAuthState()).resolves.toBeUndefined();
});
});
describe("InspectorClient runAmbientAuthChallenge branches", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
const challenge: AuthChallenge = { reason: "token_expired" };
it("returns without dispatching a recovery outcome when no oauthManager is configured", async () => {
const client = makeClient();
internalsOf(client).oauthManager = null;
const dispatch = vi
.spyOn(client, "dispatchTypedEvent")
.mockImplementation(() => {});
await client.handleAmbientAuthChallenge(challenge);
// Only the initial "ambient" announcement fires; the early return means no
// outcome-specific event (recovered/interactive/oauthError) is dispatched.
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith("authChallengeAmbient", {
challenge,
});
});
it("reconnects (rather than pushing remote auth state) for a non-remote transport on a satisfied outcome", async () => {
const client = makeClient();
const handleAuthChallenge = vi
.fn()
.mockResolvedValue({ kind: "satisfied" });
internalsOf(client).oauthManager = { handleAuthChallenge };
internalsOf(client).baseTransport = {}; // not a RemoteClientTransport instance
const reconnect = vi.fn().mockResolvedValue(undefined);
internalsOf(client).reconnectAfterAuthRecovery = reconnect;
const pushRemoteAuthState = vi
.spyOn(client, "pushRemoteAuthState")
.mockResolvedValue(undefined);
const dispatch = vi
.spyOn(client, "dispatchTypedEvent")
.mockImplementation(() => {});
await client.handleAmbientAuthChallenge(challenge);
expect(reconnect).toHaveBeenCalledTimes(1);
expect(pushRemoteAuthState).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith("authChallengeRecovered", {
challenge,
});
});
it("dispatches an interactive event with the EMA pending URL for a step_up_confirm outcome", async () => {
const client = makeClient();
const outcomeChallenge: AuthChallenge = {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
};
const handleAuthChallenge = vi.fn().mockResolvedValue({
kind: "step_up_confirm",
challenge: outcomeChallenge,
});
internalsOf(client).oauthManager = { handleAuthChallenge };
const dispatch = vi
.spyOn(client, "dispatchTypedEvent")
.mockImplementation(() => {});
await client.handleAmbientAuthChallenge(challenge);
expect(dispatch).toHaveBeenCalledWith(
"authChallengeInteractive",
expect.objectContaining({ challenge: outcomeChallenge }),
);
});
it("dispatches oauthError for a failed outcome", async () => {
const client = makeClient();
const error = new Error("recovery failed");
const handleAuthChallenge = vi
.fn()
.mockResolvedValue({ kind: "failed", error });
internalsOf(client).oauthManager = { handleAuthChallenge };
const dispatch = vi
.spyOn(client, "dispatchTypedEvent")
.mockImplementation(() => {});
await client.handleAmbientAuthChallenge(challenge);
expect(dispatch).toHaveBeenCalledWith("oauthError", { error });
});
it("wraps a non-Error thrown from handleAuthChallenge before dispatching oauthError", async () => {
const client = makeClient();
const handleAuthChallenge = vi.fn().mockRejectedValue("boom-string");
internalsOf(client).oauthManager = { handleAuthChallenge };
const dispatch = vi
.spyOn(client, "dispatchTypedEvent")
.mockImplementation(() => {});
await client.handleAmbientAuthChallenge(challenge);
const call = dispatch.mock.calls.find(([name]) => name === "oauthError");
expect(call).toBeDefined();
const detail = call?.[1] as { error: Error };
expect(detail.error).toBeInstanceOf(Error);
expect(detail.error.message).toBe("boom-string");
});
it("passes through a real Error thrown from handleAuthChallenge unwrapped", async () => {
const client = makeClient();
const thrown = new Error("direct failure");
const handleAuthChallenge = vi.fn().mockRejectedValue(thrown);
internalsOf(client).oauthManager = { handleAuthChallenge };
const dispatch = vi
.spyOn(client, "dispatchTypedEvent")
.mockImplementation(() => {});
await client.handleAmbientAuthChallenge(challenge);
const call = dispatch.mock.calls.find(([name]) => name === "oauthError");
expect(call?.[1]).toEqual({ error: thrown });
});
});
describe("InspectorClient withDirectAuthRecovery branches", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("rethrows immediately without consulting handleAuthChallenge for a non-auth-challenge error", async () => {
const client = makeClient();
internalsOf(client).directAuthRecovery = true;
internalsOf(client).directAuthRecoveryActive = true;
const operation = vi.fn().mockRejectedValue(new Error("network down"));
const handleAuthChallenge = vi.spyOn(client, "handleAuthChallenge");
await expect(
internalsOf(client).withDirectAuthRecovery.call(client, operation),
).rejects.toThrow("network down");
expect(operation).toHaveBeenCalledTimes(1);
expect(handleAuthChallenge).not.toHaveBeenCalled();
});
it("clears an active tool-call abort controller and retries after a satisfied recovery", async () => {
const client = makeClient();
internalsOf(client).directAuthRecovery = true;
internalsOf(client).directAuthRecoveryActive = true;
const abortController = new AbortController();
internalsOf(client).activeToolCallAbortController = abortController;
const reconnect = vi.fn().mockResolvedValue(undefined);
internalsOf(client).reconnectAfterAuthRecovery = reconnect;
const challenge = { reason: "token_expired" as const };
const operation = vi
.fn()
.mockRejectedValueOnce(new AuthChallengeError(challenge, 401))
.mockResolvedValueOnce("ok");
vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({
kind: "satisfied",
});
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
const result = await internalsOf(client).withDirectAuthRecovery.call(
client,
operation,
);
expect(result).toBe("ok");
expect(internalsOf(client).activeToolCallAbortController).toBeUndefined();
expect(reconnect).toHaveBeenCalledTimes(1);
expect(operation).toHaveBeenCalledTimes(2);
});
it("throws AuthRecoveryRequiredError with emaStepUpConfirm for a step_up_confirm outcome", async () => {
const client = makeClient();
internalsOf(client).directAuthRecovery = true;
internalsOf(client).directAuthRecoveryActive = true;
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
const operation = vi
.fn()
.mockRejectedValue(new AuthChallengeError(challenge, 403));
vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({
kind: "step_up_confirm",
challenge,
});
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
await expect(
internalsOf(client).withDirectAuthRecovery.call(client, operation),
).rejects.toMatchObject({
name: "AuthRecoveryRequiredError",
emaStepUpConfirm: true,
});
expect(operation).toHaveBeenCalledTimes(1);
});
});
describe("InspectorClient resumeAfterOAuth branches", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("reattaches a remote session, pushes auth state, and reconnects when not connected", async () => {
const client = makeClient();
internalsOf(client).oauthManager = {
handleAuthChallenge: vi.fn(),
completeOAuthFlow: vi.fn().mockResolvedValue(undefined),
};
internalsOf(client).directAuthRecovery = false;
internalsOf(client).directAuthRecoveryActive = null;
internalsOf(client).status = "disconnected";
const transport = Object.create(
RemoteClientTransport.prototype,
) as RemoteClientTransport;
const attachToSession = vi.fn().mockResolvedValue(undefined);
const pushAuthState = vi.fn().mockResolvedValue(undefined);
(transport as unknown as { attachToSession: unknown }).attachToSession =
attachToSession;
(transport as unknown as { pushAuthState: unknown }).pushAuthState =
pushAuthState;
internalsOf(client).baseTransport = transport;
const connectSpy = vi.spyOn(client, "connect").mockResolvedValue(undefined);
await client.resumeAfterOAuth("auth-code", {
remoteSessionId: "session-1",
});
expect(attachToSession).toHaveBeenCalledWith("session-1");
expect(pushAuthState).toHaveBeenCalledTimes(1);
expect(connectSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
describe("InspectorClient direct auth recovery retry bound", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("retries an operation once after satisfied recovery, then gives up", async () => {
const client = Object.create(InspectorClient.prototype) as InspectorClient;
const internals = client as unknown as {
directAuthRecovery: boolean;
directAuthRecoveryActive: boolean;
reconnectAfterAuthRecovery: ReturnType<typeof vi.fn>;
withDirectAuthRecovery: <T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
attempt?: number,
) => Promise<T>;
};
internals.directAuthRecovery = true;
internals.directAuthRecoveryActive = true;
internals.reconnectAfterAuthRecovery = vi.fn().mockResolvedValue(undefined);
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
const operation = vi
.fn()
.mockRejectedValue(new AuthChallengeError(challenge, 403));
vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({
kind: "satisfied",
});
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
await expect(
internals.withDirectAuthRecovery.call(client, operation, {
method: "tools/call",
toolName: "get_temp",
}),
).rejects.toBeInstanceOf(AuthChallengeError);
expect(operation).toHaveBeenCalledTimes(2);
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
expect(internals.reconnectAfterAuthRecovery).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";
import { createAuthChallengeInterceptFetch } from "@inspector/core/mcp/node/authChallengeFetch.js";
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
describe("createAuthChallengeInterceptFetch", () => {
it("passes through successful responses", async () => {
const baseFetch = vi.fn(async () => new Response("ok", { status: 200 }));
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
const res = await fetchFn("https://example.com/mcp");
expect(res.status).toBe(200);
expect(await res.text()).toBe("ok");
});
it("throws AuthChallengeError on 401", async () => {
const baseFetch = vi.fn(
async () =>
new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": 'Bearer error="invalid_token"',
},
}),
);
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf(
AuthChallengeError,
);
});
it("throws AuthChallengeError on 403 insufficient_scope", async () => {
const baseFetch = vi.fn(
async () =>
new Response(null, {
status: 403,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
}),
);
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
try {
await fetchFn("https://example.com/mcp");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(AuthChallengeError);
expect((err as AuthChallengeError).authChallenge.reason).toBe(
"insufficient_scope",
);
}
});
it("cancels a present response body before throwing", async () => {
const response = new Response("challenge body", {
status: 401,
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
});
const cancelSpy = vi.spyOn(response.body!, "cancel");
const baseFetch = vi.fn(async () => response);
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf(
AuthChallengeError,
);
expect(cancelSpy).toHaveBeenCalled();
});
it("swallows a body.cancel() rejection and still throws the challenge", async () => {
const response = new Response("challenge body", {
status: 403,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
});
vi.spyOn(response.body!, "cancel").mockRejectedValue(
new Error("cancel failed"),
);
const baseFetch = vi.fn(async () => response);
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf(
AuthChallengeError,
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,28 @@ function sseStreamResponse(): Response {
});
}
function createPushableSseStream() {
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
c.enqueue(encoder.encode(": keepalive\n\n"));
},
});
const pushMessage = (message: unknown) => {
const payload = JSON.stringify({ type: "message", data: message });
controller?.enqueue(encoder.encode(`data: ${payload}\n\n`));
};
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
pushMessage,
};
}
describe("RemoteClientTransport", () => {
it("send() throws when called before start()", async () => {
const transport = new RemoteClientTransport(
@ -384,4 +406,113 @@ describe("RemoteClientTransport", () => {
const parsed = JSON.parse(seenBodies[0]!) as Record<string, unknown>;
expect("settings" in parsed).toBe(false);
});
it("retries send once after satisfied auth challenge and pushAuthState", async () => {
let sessionCounter = 0;
let sendAttempts = 0;
let authStateUpdates = 0;
let sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
sessionCounter += 1;
return new Response(
JSON.stringify({ sessionId: `session-${sessionCounter}` }),
{ status: 200 },
);
}
if (url.includes("/api/mcp/events")) {
sse = createPushableSseStream();
return sse.response;
}
if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") {
authStateUpdates += 1;
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
sendAttempts += 1;
const body = JSON.parse(String(init.body)) as {
message: { id?: string | number };
};
if (sendAttempts === 1) {
return new Response(
JSON.stringify({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
{ status: 200 },
);
}
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: { tools: [] },
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const authProvider = {
tokens: vi.fn().mockResolvedValue({
access_token: "refreshed",
token_type: "Bearer",
}),
};
const transport = new RemoteClientTransport(
{
baseUrl,
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
authProvider: authProvider as never,
},
config,
);
transport.setAuthRecovery({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
pushAuthState: () => transport.pushAuthState(),
});
await transport.start();
await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" });
expect(sendAttempts).toBe(2);
expect(sessionCounter).toBe(1);
expect(authStateUpdates).toBe(1);
await transport.close();
});
it("forwards ambient auth_challenge SSE events to onAuthChallenge", async () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ sessionId: "abc" }), { status: 200 }),
)
.mockResolvedValueOnce(
sseResponse(
'data: {"type":"auth_challenge","data":{"reason":"token_expired"}}\n\n',
),
);
const onAuthChallenge = vi.fn();
const transport = new RemoteClientTransport(
{
baseUrl,
fetchFn: fetchFn as unknown as typeof fetch,
onAuthChallenge,
},
config,
);
await transport.start();
await vi.waitFor(() => expect(onAuthChallenge).toHaveBeenCalled(), {
timeout: 1000,
interval: 10,
});
expect(onAuthChallenge.mock.calls[0]?.[0]).toEqual({
reason: "token_expired",
});
});
});

View file

@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Request, Response, NextFunction } from "express";
import {
buildScopeRequirementRegistry,
clearOAuthTestData,
createScopeCheckMiddleware,
mintTestAccessToken,
scopeRequirementRegistryHasEntries,
} from "@modelcontextprotocol/inspector-test-server";
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
function createMinimalConfig(
overrides: Partial<ServerConfig> = {},
): ServerConfig {
return {
serverInfo: { name: "scope-test", version: "1.0.0" },
tools: [
{
name: "get_temp",
description: "mock",
requiredScopes: ["weather:read"],
handler: async () => ({
content: [{ type: "text" as const, text: "ok" }],
}),
},
],
...overrides,
};
}
function invokeScopeMiddleware(
registry: ReturnType<typeof buildScopeRequirementRegistry>,
body: unknown,
token: string,
oauthTokenScopes?: string[],
): {
status: number;
headers: Record<string, string>;
body?: unknown;
next: boolean;
} {
const middleware = createScopeCheckMiddleware(registry);
const headers: Record<string, string> = {};
let status = 200;
let responseBody: unknown;
let nextCalled = false;
const req = {
body,
oauthToken: token,
...(oauthTokenScopes !== undefined ? { oauthTokenScopes } : {}),
} as Request & { oauthToken: string; oauthTokenScopes?: string[] };
const res = {
status(code: number) {
status = code;
return this;
},
setHeader(name: string, value: string) {
headers[name.toLowerCase()] = value;
},
json(payload: unknown) {
responseBody = payload;
},
} as unknown as Response;
middleware(req, res, (() => {
nextCalled = true;
}) as NextFunction);
return { status, headers, body: responseBody, next: nextCalled };
}
describe("test server scope requirements", () => {
beforeEach(() => {
clearOAuthTestData();
});
it("builds registry from capability requiredScopes", () => {
const registry = buildScopeRequirementRegistry(createMinimalConfig());
expect(scopeRequirementRegistryHasEntries(registry)).toBe(true);
expect(registry.tools.get("get_temp")).toEqual(["weather:read"]);
});
it("returns 403 insufficient_scope when token lacks required scope", () => {
const token = mintTestAccessToken("mcp tools:read");
const registry = buildScopeRequirementRegistry(createMinimalConfig());
const result = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "get_temp", arguments: { city: "NYC", units: "C" } },
},
token,
);
expect(result.next).toBe(false);
expect(result.status).toBe(403);
expect(result.headers["www-authenticate"]).toContain("insufficient_scope");
expect(result.headers["www-authenticate"]).toContain("weather:read");
expect(result.headers["www-authenticate"]).not.toContain("tools:read");
});
it("allows request when token includes required scopes", () => {
const token = mintTestAccessToken("mcp tools:read weather:read");
const registry = buildScopeRequirementRegistry(createMinimalConfig());
const result = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "get_temp", arguments: { city: "NYC", units: "C" } },
},
token,
);
expect(result.next).toBe(true);
expect(result.status).toBe(200);
});
it("enforces requiredScopes on resource templates for resources/read", () => {
const registry = buildScopeRequirementRegistry({
...createMinimalConfig({ tools: [] }),
resourceTemplates: [
{
name: "files",
uriTemplate: "file:///{path}",
requiredScopes: ["files:read"],
handler: async () => ({
contents: [{ uri: "file:///tmp/x", text: "ok" }],
}),
},
],
});
expect(registry.resourceTemplates.get("file:///{path}")).toEqual([
"files:read",
]);
const denied = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 2,
method: "resources/read",
params: { uri: "file:///tmp/example.txt" },
},
mintTestAccessToken("mcp"),
);
expect(denied.next).toBe(false);
expect(denied.status).toBe(403);
const allowed = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 3,
method: "resources/read",
params: { uri: "file:///tmp/example.txt" },
},
mintTestAccessToken("mcp files:read"),
);
expect(allowed.next).toBe(true);
});
it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => {
const registry = buildScopeRequirementRegistry({
...createMinimalConfig(),
tools: [
{
name: "echo",
description: "mock",
requiredScopes: ["tools:read"],
handler: async () => ({
content: [{ type: "text" as const, text: "ok" }],
}),
},
],
});
const result = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "echo", arguments: { message: "hi" } },
},
"external.jwt.not.in.internal.map",
["tools:read"],
);
expect(result.next).toBe(true);
expect(result.status).toBe(200);
});
});

View file

@ -1,28 +1,53 @@
import { describe, it, expect } from "vitest";
import { createTokenAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js";
import { createRemoteAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js";
describe("createTokenAuthProvider", () => {
it("returns undefined when tokens are not provided", () => {
expect(createTokenAuthProvider(undefined)).toBeUndefined();
describe("createRemoteAuthProvider", () => {
it("returns undefined when no auth state is provided", () => {
expect(createRemoteAuthProvider(undefined)).toBeUndefined();
expect(createRemoteAuthProvider({})).toBeUndefined();
});
it("returns a provider whose tokens() resolves with the supplied tokens", async () => {
const tokens = { access_token: "abc", token_type: "Bearer" };
const provider = createTokenAuthProvider(tokens);
expect(provider).toBeDefined();
await expect(provider!.tokens()).resolves.toEqual(tokens);
const handle = createRemoteAuthProvider({ oauthTokens: tokens });
expect(handle).toBeDefined();
await expect(handle!.provider.tokens()).resolves.toEqual(tokens);
});
it("exposes no-op stubs for the auxiliary OAuthClientProvider methods", async () => {
const provider = createTokenAuthProvider({
access_token: "abc",
token_type: "Bearer",
it("updates tokens via setAuthState without replacing the provider", async () => {
const handle = createRemoteAuthProvider({
oauthTokens: { access_token: "old", token_type: "Bearer" },
});
expect(provider).toBeDefined();
// The aux methods are no-op stubs that satisfy the OAuthClientProvider
// surface; the underlying object type widens via the `as unknown as`
// cast in the source, so we narrow here for the test assertions.
const p = provider! as unknown as {
handle!.setAuthState({
oauthTokens: {
access_token: "new",
token_type: "Bearer",
refresh_token: "rt",
},
});
await expect(handle!.provider.tokens()).resolves.toEqual({
access_token: "new",
token_type: "Bearer",
refresh_token: "rt",
});
expect(handle!.getAuthState().oauthTokens?.access_token).toBe("new");
});
it("exposes clientInformation when oauthClient is set", async () => {
const handle = createRemoteAuthProvider({
oauthClient: { client_id: "cid", client_secret: "sec" },
});
await expect(handle!.provider.clientInformation()).resolves.toEqual({
client_id: "cid",
client_secret: "sec",
});
});
it("exposes no-op stubs for auxiliary OAuthClientProvider methods", async () => {
const handle = createRemoteAuthProvider({
oauthTokens: { access_token: "abc", token_type: "Bearer" },
});
const p = handle!.provider as unknown as {
clientInformation: () => Promise<undefined>;
saveTokens: (t: {
access_token: string;
@ -37,8 +62,12 @@ describe("createTokenAuthProvider", () => {
await expect(p.clientInformation()).resolves.toBeUndefined();
await expect(
p.saveTokens({ access_token: "noop", token_type: "Bearer" }),
p.saveTokens({ access_token: "saved", token_type: "Bearer" }),
).resolves.toBeUndefined();
await expect(handle!.provider.tokens()).resolves.toEqual({
access_token: "saved",
token_type: "Bearer",
});
expect(p.codeVerifier()).toBeUndefined();
await expect(p.saveCodeVerifier("v")).resolves.toBeUndefined();
expect(() => p.clear()).not.toThrow();
@ -49,10 +78,9 @@ describe("createTokenAuthProvider", () => {
});
it("exposes clientMetadata so SDK auth() does not throw on 401 retry", () => {
const provider = createTokenAuthProvider({
access_token: "abc",
token_type: "Bearer",
const handle = createRemoteAuthProvider({
oauthTokens: { access_token: "abc", token_type: "Bearer" },
});
expect(provider!.clientMetadata.scope).toBe("");
expect(handle!.provider.clientMetadata.scope).toBe("");
});
});

View file

@ -137,9 +137,8 @@ export async function createClientMetadataServer(
}
/**
* Helper function to programmatically complete OAuth authorization
* Makes HTTP GET request to authorization URL and extracts authorization code
* The test server's authorization endpoint auto-approves and redirects with code
* Programmatically complete OAuth against the local composable test AS.
* GET shows an HTML consent page; approve via POST (same as CLI test helper).
*
* @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event
* @returns Authorization code extracted from redirect URL
@ -147,10 +146,23 @@ export async function createClientMetadataServer(
export async function completeOAuthAuthorization(
authorizationUrl: URL,
): Promise<string> {
const response = await fetch(authorizationUrl.toString(), {
let response = await fetch(authorizationUrl.toString(), {
redirect: "manual",
});
if (response.status === 200) {
const body = new URLSearchParams(authorizationUrl.searchParams);
response = await fetch(
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
redirect: "manual",
},
);
}
if (response.status !== 302 && response.status !== 301) {
throw new Error(
`Expected redirect (302/301), got ${response.status}: ${await response.text()}`,
@ -162,7 +174,7 @@ export async function completeOAuthAuthorization(
throw new Error("No Location header in redirect response");
}
const redirectUrlObj = new URL(redirectUrl);
const redirectUrlObj = new URL(redirectUrl, authorizationUrl.origin);
const code = redirectUrlObj.searchParams.get("code");
if (!code) {
throw new Error(`No authorization code in redirect URL: ${redirectUrl}`);

View file

@ -0,0 +1,243 @@
/**
* Mid-session OAuth recovery over direct (TUI/CLI) transport.
* Uses createAuthChallengeInterceptFetch + handleAuthChallenge on InspectorClient.
*/
import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest";
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
import {
TestServerHttp,
waitForOAuthWellKnown,
getDefaultServerConfig,
createOAuthTestServerConfig,
clearOAuthTestData,
invalidateAccessToken,
} from "@modelcontextprotocol/inspector-test-server";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import {
createOAuthClientConfig,
completeOAuthAuthorization,
} from "../helpers/oauth-client-fixtures.js";
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
const oauthTestStatePath = join(
tmpdir(),
`mcp-oauth-${process.pid}-direct-mid-session-e2e.json`,
);
function createTestOAuthConfig(
options: Parameters<typeof createOAuthClientConfig>[0],
) {
return {
...createOAuthClientConfig(options),
storage: new NodeOAuthStorage(oauthTestStatePath),
};
}
describe("InspectorClient direct mid-session OAuth", () => {
let mcpServer: TestServerHttp | null = null;
const testRedirectUrl = "http://localhost:3000/oauth/callback";
beforeEach(() => {
clearOAuthTestData();
});
afterEach(async () => {
if (mcpServer) {
await mcpServer.stop();
mcpServer = null;
}
}, 30_000);
afterAll(() => {
try {
rmSync(oauthTestStatePath, { force: true });
} catch {
// ignore
}
});
it("recovers from invalidated access token after connect via silent refresh without disconnect", async () => {
const staticClientId = "test-direct-mid-session";
const staticClientSecret = "test-secret-direct-mid-session";
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
directAuthRecovery: true,
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const tokens = await client.getOAuthTokens();
expect(tokens?.refresh_token).toBeDefined();
invalidateAccessToken(tokens!.access_token);
const toolsResult = await client.listTools();
expect(toolsResult.tools.length).toBeGreaterThan(0);
expect(client.getStatus()).toBe("connected");
await client.disconnect();
}, 30_000);
it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => {
const staticClientId = "test-direct-step-up";
const staticClientSecret = "test-secret-direct-step-up";
const baseConfig = getDefaultServerConfig();
const serverConfig = {
...baseConfig,
serverType: "streamable-http" as const,
tools: baseConfig.tools!.map((tool) =>
tool.name === "get_temp"
? { ...tool, requiredScopes: ["weather:read"] }
: tool,
),
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
scope: "mcp tools:read",
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
directAuthRecovery: true,
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const toolsResult = await client.listTools();
const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp");
expect(getTempTool).toBeDefined();
let recovery: AuthRecoveryRequiredError | undefined;
try {
await client.callTool(getTempTool!, { city: "NYC", units: "C" });
} catch (error) {
if (error instanceof AuthRecoveryRequiredError) {
recovery = error;
} else {
throw error;
}
}
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain(
"weather:read",
);
const stepUpCode = await completeOAuthAuthorization(
recovery!.authorizationUrl,
);
await client.completeOAuthFlow(stepUpCode);
const result = await client.callTool(getTempTool!, {
city: "NYC",
units: "C",
});
expect(result.success).toBe(true);
await client.disconnect();
}, 30_000);
});

View file

@ -0,0 +1,715 @@
/**
* Mid-session OAuth recovery over the web remote transport.
* Verifies inline auth_challenge on /api/mcp/send, silent refresh, auth-state push, and retry.
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
vi,
} from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js";
import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js";
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import {
TestServerHttp,
waitForOAuthWellKnown,
getDefaultServerConfig,
createOAuthTestServerConfig,
clearOAuthTestData,
invalidateAccessToken,
} from "@modelcontextprotocol/inspector-test-server";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import {
createOAuthClientConfig,
completeOAuthAuthorization,
} from "../helpers/oauth-client-fixtures.js";
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
const oauthTestStatePath = join(
tmpdir(),
`mcp-oauth-${process.pid}-remote-mid-session-e2e.json`,
);
function createTestOAuthConfig(
options: Parameters<typeof createOAuthClientConfig>[0],
) {
return {
...createOAuthClientConfig(options),
storage: new NodeOAuthStorage(oauthTestStatePath),
};
}
async function startRemoteServer(port: number): Promise<{
baseUrl: string;
server: ServerType;
authToken: string;
}> {
const { app, authToken } = createRemoteApp({
initialConfig: { defaultEnvironment: {} },
});
return new Promise((resolve, reject) => {
const server = serve(
{ fetch: app.fetch, port, hostname: "127.0.0.1" },
(info) => {
const actualPort =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: port;
resolve({
baseUrl: `http://127.0.0.1:${actualPort}`,
server,
authToken,
});
},
);
server.on("error", reject);
});
}
describe("InspectorClient remote mid-session OAuth", () => {
let remoteServer: ServerType | null = null;
let mcpServer: TestServerHttp | null = null;
let remoteBaseUrl: string | undefined;
let remoteAuthToken: string | undefined;
const testRedirectUrl = "http://localhost:3000/oauth/callback";
beforeEach(() => {
clearOAuthTestData();
});
afterEach(async () => {
if (mcpServer) {
await mcpServer.stop();
mcpServer = null;
}
if (remoteServer) {
await new Promise<void>((resolve) => {
remoteServer!.close(() => resolve());
});
remoteServer = null;
}
remoteBaseUrl = undefined;
remoteAuthToken = undefined;
}, 30_000);
afterAll(() => {
try {
rmSync(oauthTestStatePath, { force: true });
} catch {
// ignore
}
});
async function setupRemoteServer(): Promise<void> {
const tmp = mkdtempSync(join(tmpdir(), "inspector-remote-mid-session-"));
const { baseUrl, server, authToken } = await startRemoteServer(0);
remoteBaseUrl = baseUrl;
remoteAuthToken = authToken;
remoteServer = server;
rmSync(tmp, { recursive: true, force: true });
}
it("recovers from invalidated access token after connect via silent refresh and auth-state push", async () => {
await setupRemoteServer();
const staticClientId = "test-remote-mid-session";
const staticClientSecret = "test-secret-mid-session";
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createRemoteTransport({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
fetch: createRemoteFetch({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const tokens = await client.getOAuthTokens();
expect(tokens?.refresh_token).toBeDefined();
invalidateAccessToken(tokens!.access_token);
const toolsResult = await client.listTools();
expect(toolsResult.tools.length).toBeGreaterThan(0);
expect(client.getStatus()).toBe("connected");
await client.disconnect();
}, 15_000);
it("recovers on reconnect when stored access token was invalidated before connect", async () => {
await setupRemoteServer();
const staticClientId = "test-remote-reconnect-mid-session";
const staticClientSecret = "test-secret-reconnect-mid-session";
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createRemoteTransport({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
fetch: createRemoteFetch({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const tokens = await client.getOAuthTokens();
invalidateAccessToken(tokens!.access_token);
await client.connect();
expect(client.getStatus()).toBe("connected");
await client.disconnect();
});
it("step-up re-auth after insufficient_scope lets scoped tool succeed", async () => {
await setupRemoteServer();
const staticClientId = "test-remote-step-up";
const staticClientSecret = "test-secret-step-up";
const baseConfig = getDefaultServerConfig();
const serverConfig = {
...baseConfig,
tools: baseConfig.tools!.map((tool) =>
tool.name === "get_temp"
? { ...tool, requiredScopes: ["weather:read"] }
: tool,
),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
scopesSupported: ["mcp", "tools:read", "weather:read"],
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
scope: "mcp tools:read",
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createRemoteTransport({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
fetch: createRemoteFetch({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const toolsResult = await client.listTools();
const echoTool = toolsResult.tools.find((t) => t.name === "echo");
const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp");
expect(echoTool).toBeDefined();
expect(getTempTool).toBeDefined();
await client.callTool(echoTool!, { message: "hello" });
const remoteSessionId = client.getRemoteBackendSessionId();
expect(remoteSessionId).toBeDefined();
let recovery: AuthRecoveryRequiredError | undefined;
try {
await client.callTool(getTempTool!, { city: "NYC", units: "C" });
} catch (error) {
if (error instanceof AuthRecoveryRequiredError) {
recovery = error;
} else {
throw error;
}
}
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
const mcpServerUrl = `${serverUrl}/mcp`;
const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl);
expect(scopeBeforeStepUp).toContain("mcp");
expect(scopeBeforeStepUp).not.toContain("weather:read");
expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain(
"weather:read",
);
const stepUpCode = await completeOAuthAuthorization(
recovery!.authorizationUrl,
);
// Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth
// storage, reattach to the live remote backend session.
const callbackClient = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId });
expect(callbackClient.getStatus()).toBe("connected");
const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl);
expect(scopeAfterStepUp).toContain("weather:read");
expect(scopeAfterStepUp).toContain("mcp");
const callbackTools = await callbackClient.listTools();
const callbackGetTemp = callbackTools.tools.find(
(t) => t.name === "get_temp",
);
expect(callbackGetTemp).toBeDefined();
const result = await callbackClient.callTool(callbackGetTemp!, {
city: "NYC",
units: "C",
});
expect(result.success).toBe(true);
const content = result.result?.content;
expect(Array.isArray(content)).toBe(true);
expect(content?.[0]).toHaveProperty("type", "text");
expect("text" in content![0] && content![0].text).toContain("25");
await callbackClient.disconnect();
await client.disconnect();
}, 30_000);
it("resumeAfterOAuth falls back to connect when remote session is dead", async () => {
await setupRemoteServer();
const staticClientId = "test-remote-resume-fallback";
const staticClientSecret = "test-secret-resume-fallback";
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: false,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createRemoteTransport({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
fetch: createRemoteFetch({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const initialCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(initialCode);
await client.connect();
const mcpServerUrl = `${serverUrl}/mcp`;
await oauthConfig.storage.clearTokens(mcpServerUrl);
const reauthUrl = await client.authenticate();
if (!reauthUrl) throw new Error("Expected reauth URL");
const code = await completeOAuthAuthorization(reauthUrl);
const callbackClient = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const connectSpy = vi.spyOn(callbackClient, "connect");
await callbackClient.resumeAfterOAuth(code, {
remoteSessionId: "dead-remote-session-id",
});
expect(connectSpy).toHaveBeenCalled();
connectSpy.mockRestore();
expect(callbackClient.getStatus()).toBe("connected");
const toolsResult = await callbackClient.listTools();
expect(toolsResult.tools.length).toBeGreaterThan(0);
await callbackClient.disconnect();
await client.disconnect();
}, 30_000);
it("dispatches authChallengeInteractive for ambient interactive recovery", async () => {
await setupRemoteServer();
const staticClientId = "test-remote-ambient-interactive";
const staticClientSecret = "test-secret-ambient-interactive";
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: false,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
{
environment: {
transport: createRemoteTransport({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
fetch: createRemoteFetch({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
},
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const tokens = await client.getOAuthTokens();
invalidateAccessToken(tokens!.access_token);
const interactive = vi.fn();
client.addEventListener("authChallengeInteractive", interactive);
await client.handleAmbientAuthChallenge({ reason: "token_expired" });
expect(interactive).toHaveBeenCalledOnce();
expect(interactive.mock.calls[0]![0].detail.challenge.reason).toBe(
"token_expired",
);
expect(
interactive.mock.calls[0]![0].detail.authorizationUrl,
).toBeInstanceOf(URL);
await client.disconnect();
}, 15_000);
it("recovers from ambient auth notification after idle token invalidation", async () => {
await setupRemoteServer();
const staticClientId = "test-remote-ambient-auth";
const staticClientSecret = "test-secret-ambient-auth";
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createRemoteTransport({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
fetch: createRemoteFetch({
baseUrl: remoteBaseUrl!,
authToken: remoteAuthToken!,
}),
oauth: {
storage: oauthConfig.storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
await client.connect();
const tokens = await client.getOAuthTokens();
invalidateAccessToken(tokens!.access_token);
const recovered = vi.fn();
client.addEventListener("authChallengeRecovered", recovered);
await client.handleAmbientAuthChallenge({ reason: "token_expired" });
expect(recovered).toHaveBeenCalled();
const toolsResult = await client.listTools();
expect(toolsResult.tools.length).toBeGreaterThan(0);
expect(client.getStatus()).toBe("connected");
await client.disconnect();
}, 15_000);
});

View file

@ -341,6 +341,33 @@ describe("InspectorClient", () => {
// (the same end state any other handshake failure would produce).
expect(client.getStatus()).toBe("error");
});
it("holds status at connecting when connect fails with a recoverable 401", async () => {
const unauthorizedTransport = {
start: async () => {
const err = new Error("Unauthorized") as Error & { status?: number };
err.status = 401;
throw err;
},
send: async () => {},
close: async () => {},
onclose: undefined,
onerror: undefined,
onmessage: undefined,
sessionId: undefined,
};
const fakeFactory = () => ({
transport:
unauthorizedTransport as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport,
});
client = new InspectorClient(
{ type: "streamable-http", url: "http://localhost:8081/mcp" },
{ environment: { transport: fakeFactory } },
);
await expect(client.connect()).rejects.toMatchObject({ status: 401 });
expect(client.getStatus()).toBe("connecting");
});
});
describe("Message Tracking", () => {

Some files were not shown because too many files have changed in this diff Show more