All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
21 changed files with 11437 additions and 1212 deletions
237
clients/web/src/test/core/auth/challenge.test.ts
Normal file
237
clients/web/src/test/core/auth/challenge.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
105
clients/web/src/test/core/auth/mcpAuth.test.ts
Normal file
105
clients/web/src/test/core/auth/mcpAuth.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
|
||||
const {
|
||||
sdkAuth,
|
||||
discoverOAuthServerInfo,
|
||||
startAuthorization,
|
||||
selectResourceURL,
|
||||
} = vi.hoisted(() => ({
|
||||
sdkAuth: vi.fn(),
|
||||
discoverOAuthServerInfo: vi.fn(),
|
||||
startAuthorization: vi.fn(),
|
||||
selectResourceURL: 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,
|
||||
};
|
||||
});
|
||||
|
||||
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
|
||||
|
||||
function makeProvider(): 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"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("mcpAuth", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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("uses discovery + startAuthorization when forceReauthorization is true", async () => {
|
||||
discoverOAuthServerInfo.mockResolvedValue({
|
||||
authorizationServerUrl: "https://as.example.com",
|
||||
authorizationServerMetadata: { issuer: "https://as.example.com" },
|
||||
resourceMetadata: undefined,
|
||||
});
|
||||
selectResourceURL.mockResolvedValue(undefined);
|
||||
startAuthorization.mockResolvedValue({
|
||||
authorizationUrl: new URL("https://as.example.com/authorize"),
|
||||
codeVerifier: "cv",
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
82
clients/web/src/test/core/auth/oauthUx.test.ts
Normal file
82
clients/web/src/test/core/auth/oauthUx.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
emaStepUpSuccessMessage,
|
||||
isEmaStepUp,
|
||||
isStepUpConfirmation,
|
||||
stepUpAdditionalScopes,
|
||||
stepUpConfirmMessage,
|
||||
stepUpFollowUpMessage,
|
||||
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 organization language for EMA", () => {
|
||||
expect(
|
||||
stepUpConfirmMessage(challenge, { enterpriseManaged: true }),
|
||||
).toMatch(/organization/i);
|
||||
expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch(
|
||||
/identity provider/i,
|
||||
);
|
||||
expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch(
|
||||
/organization/i,
|
||||
);
|
||||
expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe(
|
||||
"Authorize",
|
||||
);
|
||||
expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)");
|
||||
});
|
||||
|
||||
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("emaStepUpSuccessMessage suggests retry only for command-scoped recovery", () => {
|
||||
expect(emaStepUpSuccessMessage()).toBe(
|
||||
"Organization permissions were updated.",
|
||||
);
|
||||
expect(emaStepUpSuccessMessage({ recoverySource: "tool" })).toMatch(
|
||||
/Retry your action/,
|
||||
);
|
||||
});
|
||||
|
||||
it("stepUpAdditionalScopes returns requiredScopes only", () => {
|
||||
expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
283
clients/web/src/test/core/auth/runner-interactive-oauth.test.ts
Normal file
283
clients/web/src/test/core/auth/runner-interactive-oauth.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
runRunnerInteractiveOAuth,
|
||||
type RunnerInteractiveOAuthClient,
|
||||
} from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
||||
import type {
|
||||
OAuthCallbackServer,
|
||||
OAuthCallbackServerStartOptions,
|
||||
} from "@inspector/core/auth/node/oauth-callback-server.js";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
37
clients/web/src/test/core/auth/scopes.test.ts
Normal file
37
clients/web/src/test/core/auth/scopes.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,9 @@ import {
|
|||
generateOAuthState,
|
||||
parseOAuthState,
|
||||
generateOAuthErrorDescription,
|
||||
formatOAuthFailureDetail,
|
||||
} from "@inspector/core/auth/utils.js";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
describe("parseHttpUrl", () => {
|
||||
it("parses valid URLs", () => {
|
||||
|
|
@ -93,3 +95,31 @@ 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
288
clients/web/src/test/core/mcp/authResyncPropagation.test.ts
Normal file
288
clients/web/src/test/core/mcp/authResyncPropagation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -14,19 +14,16 @@ import {
|
|||
emaClientNotConfiguredMessage,
|
||||
} from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
|
||||
|
||||
// Mock the SDK auth() entry point so quick-flow paths don't hit the network.
|
||||
// Other named exports of the module are left intact for code that uses them.
|
||||
vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => {
|
||||
// Mock mcpAuth so OAuthManager tests do not hit the network.
|
||||
vi.mock("@inspector/core/auth/mcpAuth.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@modelcontextprotocol/sdk/client/auth.js")
|
||||
>();
|
||||
return { ...actual, auth: vi.fn() };
|
||||
await importOriginal<typeof import("@inspector/core/auth/mcpAuth.js")>();
|
||||
return { ...actual, mcpAuth: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const mockedMcpAuth = vi.mocked(mcpAuth);
|
||||
|
||||
const SERVER_URL = "https://example.com/mcp";
|
||||
|
||||
|
|
@ -100,7 +97,7 @@ function storageOf(params: OAuthManagerParams): MockStorage {
|
|||
|
||||
describe("OAuthManager", () => {
|
||||
beforeEach(() => {
|
||||
mockedAuth.mockReset();
|
||||
mockedMcpAuth.mockReset();
|
||||
});
|
||||
|
||||
describe("setOAuthConfig", () => {
|
||||
|
|
@ -398,7 +395,7 @@ describe("OAuthManager", () => {
|
|||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=abc",
|
||||
);
|
||||
mockedAuth.mockResolvedValue("REDIRECT");
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const parseSpy = vi
|
||||
.spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState")
|
||||
.mockReturnValue({
|
||||
|
|
@ -412,6 +409,7 @@ describe("OAuthManager", () => {
|
|||
const params = createMockParams({ onBeforeOAuthRedirect });
|
||||
// A configured scope exercises the saveScope branch in createOAuthProvider.
|
||||
params.initialConfig.scope = "read write";
|
||||
storageOf(params).getScope.mockReturnValue(undefined);
|
||||
storageOf(params).getClientInformation.mockResolvedValue({
|
||||
client_id: "cid",
|
||||
});
|
||||
|
|
@ -442,8 +440,40 @@ describe("OAuthManager", () => {
|
|||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("preserves stored scope instead of resetting to config scope", async () => {
|
||||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=abc",
|
||||
);
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
params.initialConfig.scope = "mcp tools:read";
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read weather:read");
|
||||
storageOf(params).getClientInformation.mockResolvedValue({
|
||||
client_id: "cid",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(capturedUrl);
|
||||
|
||||
await manager.authenticate();
|
||||
|
||||
expect(storageOf(params).saveScope).not.toHaveBeenCalled();
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
scope: "mcp tools:read weather:read",
|
||||
}),
|
||||
);
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws when auth() unexpectedly returns AUTHORIZED", async () => {
|
||||
mockedAuth.mockResolvedValue("AUTHORIZED");
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
await expect(manager.authenticate()).rejects.toThrow(
|
||||
"Unexpected: auth() returned AUTHORIZED without authorization code",
|
||||
|
|
@ -451,7 +481,7 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
it("throws when no authorization URL is captured", async () => {
|
||||
mockedAuth.mockResolvedValue("REDIRECT");
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
// Default provider captures nothing (auth() is mocked and never redirects).
|
||||
await expect(manager.authenticate()).rejects.toThrow(
|
||||
|
|
@ -463,7 +493,7 @@ describe("OAuthManager", () => {
|
|||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=zzz",
|
||||
);
|
||||
mockedAuth.mockResolvedValue("REDIRECT");
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const parseSpy = vi
|
||||
.spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState")
|
||||
.mockReturnValue(null);
|
||||
|
|
@ -489,7 +519,7 @@ describe("OAuthManager", () => {
|
|||
describe("completeOAuthFlow (quick, standard)", () => {
|
||||
it("completes via the quick path and dispatches complete", async () => {
|
||||
const tokens = { access_token: "QT", token_type: "Bearer" };
|
||||
mockedAuth.mockResolvedValue("AUTHORIZED");
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue(tokens);
|
||||
storageOf(params).getClientInformation.mockResolvedValue({
|
||||
|
|
@ -505,7 +535,7 @@ describe("OAuthManager", () => {
|
|||
|
||||
it("throws and dispatches error when auth() is not AUTHORIZED", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockedAuth.mockResolvedValue("REDIRECT");
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
|
|
@ -517,7 +547,7 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
it("throws when tokens cannot be retrieved after authorization", async () => {
|
||||
mockedAuth.mockResolvedValue("AUTHORIZED");
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue(undefined);
|
||||
const manager = new OAuthManager(params);
|
||||
|
|
@ -527,6 +557,55 @@ describe("OAuthManager", () => {
|
|||
);
|
||||
expect(params.dispatchOAuthError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears pending step-up scope when completeOAuthFlow fails", async () => {
|
||||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=step-up",
|
||||
);
|
||||
mockedMcpAuth
|
||||
.mockResolvedValueOnce("REDIRECT")
|
||||
.mockResolvedValueOnce("REDIRECT")
|
||||
.mockResolvedValueOnce("AUTHORIZED");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getScope.mockReturnValue("mcp");
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp",
|
||||
});
|
||||
storageOf(params).getClientInformation.mockResolvedValue({
|
||||
client_id: "cid",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(capturedUrl);
|
||||
|
||||
await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
});
|
||||
|
||||
await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow();
|
||||
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "access",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp",
|
||||
});
|
||||
await manager.completeOAuthFlow("good-code");
|
||||
|
||||
expect(storageOf(params).saveScope).toHaveBeenLastCalledWith(
|
||||
SERVER_URL,
|
||||
"mcp",
|
||||
);
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeOAuthFlow (EMA)", () => {
|
||||
|
|
@ -717,6 +796,522 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("checkAuthChallengeSatisfied", () => {
|
||||
it("returns false when no tokens in storage", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue(undefined);
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
expect(
|
||||
await manager.checkAuthChallengeSatisfied({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["tools:write"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for token_expired when a usable access token exists", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
expect(
|
||||
await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when stored scope covers step-up union", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read tools:write",
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write");
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
expect(
|
||||
await manager.checkAuthChallengeSatisfied({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["tools:write"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when step-up union exceeds granted scope", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read");
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
expect(
|
||||
await manager.checkAuthChallengeSatisfied({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["tools:write"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for insufficient_scope with no scopes in the challenge", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue(undefined);
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
expect(
|
||||
await manager.checkAuthChallengeSatisfied({
|
||||
reason: "insufficient_scope",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("short-circuits handleAuthChallenge when scope already satisfied", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read tools:write",
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write");
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["tools:write"],
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ kind: "satisfied" });
|
||||
expect(mockedMcpAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not short-circuit token_expired at handleAuthChallenge entry", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "token_expired",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ kind: "satisfied" });
|
||||
expect(mockedMcpAuth).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuthChallenge", () => {
|
||||
it("returns satisfied when silent refresh succeeds", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "token_expired",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ kind: "satisfied" });
|
||||
});
|
||||
|
||||
it("returns interactive when refresh requires redirect without navigating", async () => {
|
||||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=abc",
|
||||
);
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(capturedUrl);
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "token_expired",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "interactive",
|
||||
authorizationUrl: capturedUrl,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
params.initialConfig.navigation!.navigateToAuthorization,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(manager.getOAuthFlowStep()).toBe("authorization_code");
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses catalog scope for reauth interactive flows", async () => {
|
||||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=reauth",
|
||||
);
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ scope: "catalog:scope" });
|
||||
storageOf(params).getScope.mockReturnValue("stored union scope");
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(capturedUrl);
|
||||
|
||||
await manager.handleAuthChallenge({ reason: "token_expired" });
|
||||
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ scope: "catalog:scope" }),
|
||||
);
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns failed with step-up message when silent refresh grants insufficient scope", async () => {
|
||||
mockedMcpAuth
|
||||
.mockResolvedValueOnce("AUTHORIZED")
|
||||
.mockResolvedValueOnce("AUTHORIZED");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "access",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read");
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
context: { toolName: "get_weather" },
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe("failed");
|
||||
if (outcome.kind === "failed") {
|
||||
expect(outcome.error.message).toMatch(/get_weather/);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns failed when no authorization URL is captured", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "unauthorized",
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe("failed");
|
||||
if (outcome.kind === "failed") {
|
||||
expect(outcome.error.message).toMatch(
|
||||
/Failed to capture authorization URL/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns interactive for insufficient_scope without navigating", async () => {
|
||||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=step-up",
|
||||
);
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read");
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(capturedUrl);
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
});
|
||||
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "interactive",
|
||||
authorizationUrl: capturedUrl,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
params.initialConfig.navigation!.navigateToAuthorization,
|
||||
).not.toHaveBeenCalled();
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("unions scopes and starts interactive step-up for insufficient_scope", async () => {
|
||||
const capturedUrl = new URL(
|
||||
"https://auth.example.com/authorize?state=step-up",
|
||||
);
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read");
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(capturedUrl);
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
});
|
||||
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "interactive",
|
||||
authorizationUrl: capturedUrl,
|
||||
}),
|
||||
);
|
||||
expect(storageOf(params).saveScope).not.toHaveBeenCalled();
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
scope: "mcp tools:read weather:read",
|
||||
forceReauthorization: true,
|
||||
}),
|
||||
);
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns satisfied for EMA silent refresh", async () => {
|
||||
const refreshSpy = vi
|
||||
.spyOn(emaFlow, "refreshEmaResourceTokens")
|
||||
.mockResolvedValue(undefined);
|
||||
const authUrl = new URL("https://idp.example.com/authorize?state=ema");
|
||||
const startSpy = vi
|
||||
.spyOn(emaFlow, "startEmaIdpAuthorization")
|
||||
.mockResolvedValue(authUrl);
|
||||
const params = createMockParams({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "token_expired",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "interactive",
|
||||
authorizationUrl: authUrl,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
params.initialConfig.navigation!.navigateToAuthorization,
|
||||
).not.toHaveBeenCalled();
|
||||
refreshSpy.mockRestore();
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns step_up_confirm for EMA insufficient_scope until user confirms", async () => {
|
||||
const silentSpy = vi
|
||||
.spyOn(emaFlow, "trySilentEmaAuth")
|
||||
.mockResolvedValue({ status: "success" });
|
||||
const params = createMockParams({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp");
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe("step_up_confirm");
|
||||
if (outcome.kind === "step_up_confirm") {
|
||||
expect(outcome.challenge.authorizationScopes).toEqual([
|
||||
"mcp",
|
||||
"weather:read",
|
||||
]);
|
||||
}
|
||||
expect(silentSpy).not.toHaveBeenCalled();
|
||||
silentSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("step_up_confirm lists only scopes not already granted when AS sends full tool requirements", async () => {
|
||||
const silentSpy = vi
|
||||
.spyOn(emaFlow, "trySilentEmaAuth")
|
||||
.mockResolvedValue({ status: "success" });
|
||||
const params = createMockParams({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read");
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["tools:read", "env:read"],
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe("step_up_confirm");
|
||||
if (outcome.kind === "step_up_confirm") {
|
||||
expect(outcome.challenge.requiredScopes).toEqual(["env:read"]);
|
||||
expect(outcome.challenge.authorizationScopes).toEqual([
|
||||
"mcp",
|
||||
"tools:read",
|
||||
"env:read",
|
||||
]);
|
||||
}
|
||||
silentSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns satisfied for EMA insufficient_scope after user confirms", async () => {
|
||||
const silentSpy = vi
|
||||
.spyOn(emaFlow, "trySilentEmaAuth")
|
||||
.mockResolvedValue({ status: "success" });
|
||||
const params = createMockParams({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
let storedScope = "mcp";
|
||||
storageOf(params).getScope.mockImplementation(() => storedScope);
|
||||
storageOf(params).saveScope.mockImplementation(async (_url, scope) => {
|
||||
storedScope = scope;
|
||||
});
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
|
||||
const outcome = await manager.handleAuthChallenge(
|
||||
{
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
},
|
||||
{ confirmedStepUp: true },
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ kind: "satisfied" });
|
||||
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
"mcp weather:read",
|
||||
);
|
||||
expect(silentSpy).toHaveBeenCalled();
|
||||
silentSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("completeOAuthFlow mints EMA tokens with pending step-up union scope", async () => {
|
||||
const silentSpy = vi
|
||||
.spyOn(emaFlow, "trySilentEmaAuth")
|
||||
.mockResolvedValue({ status: "no_idp_session" });
|
||||
const authUrl = new URL("https://idp.example.com/authorize?state=ema");
|
||||
const startSpy = vi
|
||||
.spyOn(emaFlow, "startEmaIdpAuthorization")
|
||||
.mockResolvedValue(authUrl);
|
||||
const mintSpy = vi
|
||||
.spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint")
|
||||
.mockResolvedValue({ access_token: "tok", token_type: "Bearer" });
|
||||
const params = createMockParams({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
storageOf(params).getScope.mockReturnValue("mcp tools:read");
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "old",
|
||||
token_type: "Bearer",
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true, scope: "mcp" });
|
||||
|
||||
const outcome = await manager.handleAuthChallenge(
|
||||
{
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
},
|
||||
{ confirmedStepUp: true },
|
||||
);
|
||||
expect(outcome.kind).toBe("interactive");
|
||||
|
||||
await manager.completeOAuthFlow("auth-code");
|
||||
|
||||
expect(mintSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scope: "mcp tools:read weather:read" }),
|
||||
"auth-code",
|
||||
);
|
||||
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
"mcp tools:read weather:read",
|
||||
);
|
||||
|
||||
silentSpy.mockRestore();
|
||||
startSpy.mockRestore();
|
||||
mintSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOAuthProviderForTransport", () => {
|
||||
it("returns a plain provider for standard OAuth", async () => {
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
153
clients/web/src/test/core/mcp/test-server-scope.test.ts
Normal file
153
clients/web/src/test/core/mcp/test-server-scope.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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("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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
|
||||
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
||||
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
|
||||
|
||||
function makeFetchEntry(
|
||||
overrides: Partial<FetchRequestEntryBase> = {},
|
||||
|
|
@ -173,4 +174,113 @@ describe("RemoteSession", () => {
|
|||
"2026-01-01T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("waitForRequestResponse resolves when a matching JSON-RPC response arrives", async () => {
|
||||
const session = new RemoteSession("s-wait");
|
||||
const wait = session.waitForRequestResponse(42);
|
||||
session.onMessage({ jsonrpc: "2.0", id: 42, result: { tools: [] } });
|
||||
await expect(wait).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("handleTransportAuthError rejects active request waits during send", async () => {
|
||||
const session = new RemoteSession("s-auth");
|
||||
session.beginSend();
|
||||
const wait = session.waitForRequestResponse(1);
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
await expect(wait).rejects.toBe(err);
|
||||
session.endSend();
|
||||
});
|
||||
|
||||
it("handleTransportAuthError pushes ambient auth when no send is active", () => {
|
||||
const session = new RemoteSession("s-ambient");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not push SSE auth while a send is active (command path owns delivery)", () => {
|
||||
const session = new RemoteSession("s-active");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
session.beginSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
session.endSend();
|
||||
});
|
||||
|
||||
it("does not duplicate on SSE until the HTTP echo suppress window expires", () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new RemoteSession("s-echo");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
session.beginSend();
|
||||
session.noteAuthChallengeDeliveredViaHttp();
|
||||
session.endSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
session.beginSend();
|
||||
session.endSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
vi.advanceTimersByTime(RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS + 1);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not clear HTTP auth suppression when a concurrent send starts", () => {
|
||||
const session = new RemoteSession("s-concurrent");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
session.beginSend();
|
||||
session.noteAuthChallengeDeliveredViaHttp();
|
||||
session.endSend();
|
||||
session.beginSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
session.endSend();
|
||||
});
|
||||
|
||||
it("waitForRequestResponse rejects after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new RemoteSession("s-timeout");
|
||||
const wait = session.waitForRequestResponse(99, 1000);
|
||||
const rejection = expect(wait).rejects.toThrow(/timed out/);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await rejection;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("setAuthState updates the session auth provider", async () => {
|
||||
const { createRemoteAuthProvider } =
|
||||
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");
|
||||
const handle = createRemoteAuthProvider({
|
||||
oauthTokens: { access_token: "old", token_type: "Bearer" },
|
||||
})!;
|
||||
const session = new RemoteSession("auth-state");
|
||||
session.setAuthProviderHandle(handle);
|
||||
session.setAuthState({
|
||||
oauthTokens: { access_token: "new", token_type: "Bearer" },
|
||||
});
|
||||
await expect(handle.provider.tokens()).resolves.toEqual({
|
||||
access_token: "new",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,10 +53,22 @@ function sseFrame(event: RemoteEvent): string {
|
|||
}
|
||||
|
||||
interface MockFetchPlan {
|
||||
connect?: () => Response | Promise<Response>;
|
||||
events?: () => Response | Promise<Response>;
|
||||
send?: () => Response | Promise<Response>;
|
||||
disconnect?: () => Response | Promise<Response>;
|
||||
connect?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
events?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
send?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
disconnect?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,24 +76,28 @@ interface MockFetchPlan {
|
|||
* handlers, defaulting to sensible success responses when a handler is omitted.
|
||||
*/
|
||||
function mockFetch(plan: MockFetchPlan): typeof fetch {
|
||||
const fn = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/mcp/connect")) {
|
||||
return plan.connect
|
||||
? plan.connect()
|
||||
: jsonResponse({ sessionId: "sess-1" });
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
return plan.events ? plan.events() : sseResponse([]);
|
||||
}
|
||||
if (url.includes("/api/mcp/send")) {
|
||||
return plan.send ? plan.send() : jsonResponse({ ok: true });
|
||||
}
|
||||
if (url.includes("/api/mcp/disconnect")) {
|
||||
return plan.disconnect ? plan.disconnect() : jsonResponse({ ok: true });
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${url}`);
|
||||
});
|
||||
const fn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/mcp/connect")) {
|
||||
return plan.connect
|
||||
? plan.connect(input, init)
|
||||
: jsonResponse({ sessionId: "sess-1" });
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
return plan.events ? plan.events(input, init) : sseResponse([]);
|
||||
}
|
||||
if (url.includes("/api/mcp/send")) {
|
||||
return plan.send ? plan.send(input, init) : jsonResponse({ ok: true });
|
||||
}
|
||||
if (url.includes("/api/mcp/disconnect")) {
|
||||
return plan.disconnect
|
||||
? plan.disconnect(input, init)
|
||||
: jsonResponse({ ok: true });
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${url}`);
|
||||
},
|
||||
);
|
||||
return fn as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +188,9 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
);
|
||||
await t.start();
|
||||
expect(connectBody).toMatchObject({
|
||||
oauthTokens: { access_token: "AT", refresh_token: "RT" },
|
||||
authState: {
|
||||
oauthTokens: { access_token: "AT", refresh_token: "RT" },
|
||||
},
|
||||
});
|
||||
await t.close();
|
||||
});
|
||||
|
|
@ -497,17 +515,38 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
|
||||
it("posts a message including relatedRequestId and succeeds", async () => {
|
||||
let sentBody: { relatedRequestId?: unknown } | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | null =
|
||||
null;
|
||||
const pushSseMessage = (message: JSONRPCMessage) => {
|
||||
const payload = JSON.stringify({ type: "message", data: message });
|
||||
sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`));
|
||||
};
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/connect")) return jsonResponse({ sessionId: "s" });
|
||||
if (url.includes("/events"))
|
||||
if (url.includes("/events")) {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({ start() {} }),
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/send")) {
|
||||
sentBody = JSON.parse(init!.body as string);
|
||||
const requestId = (
|
||||
sentBody as { message: { id?: string | number } }
|
||||
).message.id;
|
||||
pushSseMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: requestId!,
|
||||
result: {},
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
|
|
@ -517,6 +556,7 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
{
|
||||
baseUrl: "http://remote.test",
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
sseResponseTimeoutMs: 2000,
|
||||
},
|
||||
CONFIG,
|
||||
);
|
||||
|
|
@ -577,6 +617,22 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("attachToSession", () => {
|
||||
it("opens SSE for an existing session without POST /connect", async () => {
|
||||
const connect = vi.fn(() => jsonResponse({ sessionId: "new" }));
|
||||
const events = vi.fn(() => sseResponse([]));
|
||||
const t = makeTransport({ connect, events });
|
||||
await t.attachToSession("existing-sess");
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
expect(events).toHaveBeenCalledWith(
|
||||
expect.stringContaining("sessionId=existing-sess"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(t.getRemoteBackendSessionId()).toBe("existing-sess");
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionId getter", () => {
|
||||
it("always returns undefined (intentional)", () => {
|
||||
const t = makeTransport({});
|
||||
|
|
|
|||
|
|
@ -1340,7 +1340,7 @@ describe("Remote transport e2e", () => {
|
|||
expect((await res.json()).error).toMatch(/Failed to create transport:/);
|
||||
});
|
||||
|
||||
it("/api/mcp/send returns 500 when transport is dead", async () => {
|
||||
it("/api/mcp/send returns transport_error when transport is dead", async () => {
|
||||
mcpHttpServer = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
|
|
@ -1387,9 +1387,11 @@ describe("Remote transport e2e", () => {
|
|||
message: { jsonrpc: "2.0", id: 99, method: "ping" },
|
||||
}),
|
||||
});
|
||||
// Either the dead-transport short-circuit (500) or a downstream send
|
||||
// failure (500) — either way, the path is covered.
|
||||
expect(sendRes.status).toBeGreaterThanOrEqual(500);
|
||||
expect(sendRes.status).toBe(200);
|
||||
expect(await sendRes.json()).toMatchObject({
|
||||
ok: false,
|
||||
kind: "transport_error",
|
||||
});
|
||||
});
|
||||
|
||||
it("/api/log accepts non-JSON body silently via the catch fallback", async () => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue