inspector/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts
Bob Dickinson a71e6d2012
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725)
Close #1527 acceptance gaps with automated tests (SEP-2468 iss, SEP-2207 offline_access, SEP-2351 discovery shapes, SEP-2352 AS-migration, EraNegotiationFailed→401 unwrap); lean EMA leg 2 and scope helpers on the v2 SDK; drop deprecated TS baseUrl. Reviewed LGTM.
2026-07-19 22:36:21 -04:00

1501 lines
50 KiB
TypeScript

/**
* End-to-end OAuth tests for InspectorClient
* These tests require a test server with OAuth enabled
* Tests are parameterized to run against both SSE and streamable-http transports
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
vi,
} from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { FetchRequestLogState } from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
TestServerHttp,
waitForOAuthWellKnown,
getDefaultServerConfig,
createOAuthTestServerConfig,
clearOAuthTestData,
getDCRRequests,
invalidateAccessToken,
} from "@modelcontextprotocol/inspector-test-server";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js";
import {
createOAuthClientConfig,
completeOAuthAuthorization,
createClientMetadataServer,
type ClientMetadataDocument,
} from "../helpers/oauth-client-fixtures.js";
import {
clearAllOAuthClientState,
NodeOAuthStorage,
} from "@inspector/core/auth/node/index.js";
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
const oauthTestStatePath = path.join(
os.tmpdir(),
`mcp-oauth-${process.pid}-inspectorClient-oauth-e2e.json`,
);
function createTestOAuthConfig(
options: Parameters<typeof createOAuthClientConfig>[0],
) {
return {
...createOAuthClientConfig(options),
storage: new NodeOAuthStorage(oauthTestStatePath),
};
}
interface TransportConfig {
name: string;
serverType: "sse" | "streamable-http";
clientType: "sse" | "streamable-http";
endpoint: string; // "/sse" or "/mcp"
}
const transports: TransportConfig[] = [
{
name: "SSE",
serverType: "sse",
clientType: "sse",
endpoint: "/sse",
},
{
name: "Streamable HTTP",
serverType: "streamable-http",
clientType: "streamable-http",
endpoint: "/mcp",
},
];
describe("InspectorClient OAuth E2E", () => {
let server: TestServerHttp;
let client: InspectorClient;
const testRedirectUrl = "http://localhost:3001/oauth/callback";
afterAll(async () => {
try {
await fs.unlink(oauthTestStatePath);
} catch {
// Ignore if file does not exist or already removed
}
});
beforeEach(async () => {
clearOAuthTestData();
await clearAllOAuthClientState();
// Capture console.log output instead of printing to stdout during tests
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(async () => {
if (client) {
await client.disconnect();
}
if (server) {
await server.stop();
}
// Restore console.log after each test
vi.restoreAllMocks();
});
describe.each(transports)(
"Static/Preregistered Client Mode ($name)",
(transport) => {
it("should complete OAuth flow with static client", async () => {
const staticClientId = "test-static-client";
const staticClientSecret = "test-static-secret";
// Create test server with OAuth enabled and static client
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
await waitForOAuthWellKnown(serverUrl);
// Create client with static OAuth config
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const stateAfterAuth = client.getOAuthFlowState();
expect(stateAfterAuth?.oauthStep).toBe("authorization_code");
expect(stateAfterAuth?.authorizationUrl?.href).toBe(authUrl.href);
expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBe(staticClientId);
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.oauthStep).toBe("complete");
expect(stateAfterComplete?.oauthTokens).toBeDefined();
expect(stateAfterComplete?.completedAt).toBeDefined();
// Verify tokens are stored
const tokens = await client.getOAuthTokens();
expect(tokens).toBeDefined();
expect(tokens?.access_token).toBeDefined();
expect(tokens?.token_type).toBe("Bearer");
// Connection should now be successful
expect(client.getStatus()).toBe("connected");
});
it("should retry original request after OAuth completion", async () => {
const staticClientId = "test-static-client-2";
const staticClientSecret = "test-static-secret-2";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
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: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
// Auth-provider flow: authenticate first, complete OAuth, then connect.
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
expect(client.getStatus()).toBe("connected");
const toolsResult = await client.listTools();
expect(toolsResult).toBeDefined();
});
},
);
describe.each(transports)(
"CIMD (Client ID Metadata Documents) Mode ($name)",
(transport) => {
let metadataServer: { url: string; stop: () => Promise<void> } | null =
null;
afterEach(async () => {
if (metadataServer) {
await metadataServer.stop();
metadataServer = null;
}
});
it("should complete OAuth flow with CIMD client", async () => {
const testRedirectUrl = "http://localhost:3001/oauth/callback";
// Create client metadata document
const clientMetadata: ClientMetadataDocument = {
redirect_uris: [testRedirectUrl],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector Test Client",
client_uri: "https://github.com/modelcontextprotocol/inspector",
scope: "mcp",
};
// Start metadata server
metadataServer = await createClientMetadataServer(clientMetadata);
const metadataUrl = metadataServer.url;
// Create test server with OAuth enabled and CIMD support
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportCIMD: true,
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
await waitForOAuthWellKnown(serverUrl);
// Create client with CIMD config
const oauthConfig = createTestOAuthConfig({
mode: "cimd",
clientMetadataUrl: metadataUrl,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
// CIMD pre-registration via authenticate() (supports http:// test metadata URLs)
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
// Verify tokens are stored
const tokens = await client.getOAuthTokens();
expect(tokens).toBeDefined();
expect(tokens?.access_token).toBeDefined();
expect(tokens?.token_type).toBe("Bearer");
// Connection should now be successful
expect(client.getStatus()).toBe("connected");
});
it("should retry original request after OAuth completion with CIMD", async () => {
const testRedirectUrl = "http://localhost:3001/oauth/callback";
const clientMetadata: ClientMetadataDocument = {
redirect_uris: [testRedirectUrl],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector Test Client",
scope: "mcp",
};
metadataServer = await createClientMetadataServer(clientMetadata);
const metadataUrl = metadataServer.url;
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportCIMD: true,
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "cimd",
clientMetadataUrl: metadataUrl,
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
expect(client.getStatus()).toBe("connected");
const toolsResult = await client.listTools();
expect(toolsResult).toBeDefined();
});
},
);
describe.each(transports)(
"DCR (Dynamic Client Registration) Mode ($name)",
(transport) => {
it("should register client and complete OAuth flow", async () => {
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "dcr",
redirectUrl: testRedirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const stateAfterAuth = client.getOAuthFlowState();
expect(stateAfterAuth?.oauthStep).toBe("authorization_code");
expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined();
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.oauthStep).toBe("complete");
expect(stateAfterComplete?.oauthTokens).toBeDefined();
expect(stateAfterComplete?.completedAt).toBeDefined();
const tokens = await client.getOAuthTokens();
expect(tokens).toBeDefined();
expect(tokens?.access_token).toBeDefined();
expect(client.getStatus()).toBe("connected");
});
},
);
describe.each(transports)(
"Single redirect URL (DCR) ($name)",
(transport) => {
const redirectUrl = testRedirectUrl;
it("should include single redirect_uri in DCR registration", async () => {
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "dcr",
redirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
const dcr = getDCRRequests();
expect(dcr.length).toBeGreaterThanOrEqual(1);
const uris = dcr[dcr.length - 1]!.redirect_uris;
expect(uris).toEqual([redirectUrl]);
});
it("should accept single redirect_uri on re-authentication", async () => {
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "dcr",
redirectUrl,
});
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrlFirst = await client.authenticate();
if (!authUrlFirst) throw new Error("Expected authorization URL");
const { code: authCodeFirst, iss: authCodeFirstIss } =
await completeOAuthAuthorization(authUrlFirst);
await client.completeOAuthFlow(authCodeFirst, authCodeFirstIss);
await client.connect();
expect(client.getStatus()).toBe("connected");
await client.disconnect();
await client.clearOAuthTokens();
const authUrlSecond = await client.authenticate();
if (!authUrlSecond) throw new Error("Expected authorization URL");
const { code: authCodeSecond, iss: authCodeSecondIss } =
await completeOAuthAuthorization(authUrlSecond);
await client.completeOAuthFlow(authCodeSecond, authCodeSecondIss);
await client.connect();
expect(client.getStatus()).toBe("connected");
});
},
);
describe.each(transports)("401 Error Handling ($name)", (transport) => {
it("should dispatch oauthAuthorizationRequired when authenticating", async () => {
const staticClientId = "test-client-401";
const staticClientSecret = "test-secret-401";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.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: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
let authEventReceived = false;
client.addEventListener("oauthAuthorizationRequired", (event) => {
authEventReceived = true;
expect(event.detail.url).toBeInstanceOf(URL);
});
await client.authenticate();
expect(authEventReceived).toBe(true);
});
it("should not open the browser during connect when no tokens are stored", async () => {
const staticClientId = "test-client-connect-no-nav";
const staticClientSecret = "test-secret-connect-no-nav";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const navigate = vi.fn();
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
oauthConfig.navigation = { navigateToAuthorization: navigate };
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
await expect(client.connect()).rejects.toThrow();
expect(navigate).not.toHaveBeenCalled();
});
it("should connect on retry after OAuth without disconnect", async () => {
const staticClientId = "test-client-connect-retry-no-disconnect";
const staticClientSecret = "test-secret-connect-retry-no-disconnect";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const navigate = vi.fn();
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
oauthConfig.navigation = { navigateToAuthorization: navigate };
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
await expect(client.connect()).rejects.toThrow();
expect(navigate).not.toHaveBeenCalled();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
expect(await client.isOAuthAuthorized()).toBe(true);
});
});
describe.each(transports)(
"Token refresh (authProvider) ($name)",
(transport) => {
it("should persist refresh_token and succeed connect after 401 via refresh", async () => {
const staticClientId = "test-refresh";
const staticClientSecret = "test-secret-refresh";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
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: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
const tokens = await client.getOAuthTokens();
expect(tokens).toBeDefined();
expect(tokens?.access_token).toBeDefined();
expect(tokens?.refresh_token).toBeDefined();
invalidateAccessToken(tokens!.access_token);
await client.disconnect();
await client.connect();
expect(client.getStatus()).toBe("connected");
const toolsResult = await client.listTools();
expect(toolsResult).toBeDefined();
});
},
);
describe.each(transports)("Token Management ($name)", (transport) => {
it("should store and retrieve OAuth tokens", async () => {
const staticClientId = "test-client-tokens";
const staticClientSecret = "test-secret-tokens";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.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: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
const tokens = await client.getOAuthTokens();
expect(tokens).toBeDefined();
expect(tokens?.access_token).toBeDefined();
expect(await client.isOAuthAuthorized()).toBe(true);
await client.clearOAuthTokens();
expect(await client.isOAuthAuthorized()).toBe(false);
expect(await client.getOAuthTokens()).toBeUndefined();
});
});
describe.each(transports)("Storage path (custom) ($name)", (transport) => {
it("should persist OAuth state to custom storagePath", async () => {
const customPath = path.join(
os.tmpdir(),
`mcp-inspector-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const staticClientId = "test-storage-path";
const staticClientSecret = "test-secret-sp";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.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: new NodeOAuthStorage(customPath),
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
try {
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
expect(client.getStatus()).toBe("connected");
type StoredTokens = { tokens?: { access_token?: string } };
type StateShape = {
servers?: Record<
string,
StoredTokens & { byIssuer?: Record<string, StoredTokens> }
>;
};
// Persistence is fire-and-forget; await the write rather than polling.
await flushStoreFileWrites(customPath);
const parsed = JSON.parse(
await fs.readFile(customPath, "utf-8"),
) as StateShape;
const servers = parsed.servers ?? {};
expect(Object.keys(servers).length).toBeGreaterThan(0);
// SEP-2352: tokens persist under `byIssuer[issuer].tokens`; accept the
// legacy top-level slot too.
expect(
Object.values(servers).some(
(s) =>
!!s?.tokens?.access_token ||
Object.values(s?.byIssuer ?? {}).some(
(slot) => !!slot?.tokens?.access_token,
),
),
).toBe(true);
} finally {
try {
await fs.unlink(customPath);
} catch {
/* ignore */
}
}
});
});
describe("fetchFn integration", () => {
it("should use provided fetchFn for OAuth HTTP requests", async () => {
const tracker: Array<{ url: string; method: string }> = [];
const fetchFn: typeof fetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => {
tracker.push({
url: typeof input === "string" ? input : input.toString(),
method: init?.method ?? "GET",
});
return fetch(input, init);
};
const staticClientId = "test-fetchFn-client";
const staticClientSecret = "test-fetchFn-secret";
const transport = transports[0]!;
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.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,
fetch: fetchFn,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
clientMetadataUrl: oauthConfig.clientMetadataUrl,
scope: oauthConfig.scope,
},
};
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
clientConfig,
);
const fetchRequestLogState = new FetchRequestLogState(client);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const { code: authCode, iss: authCodeIss } =
await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect();
expect(client.getStatus()).toBe("connected");
expect(tracker.length).toBeGreaterThan(0);
const oauthUrls = tracker.filter(
(c) =>
c.url.includes("well-known") ||
c.url.includes("/oauth/") ||
c.url.includes("token"),
);
expect(oauthUrls.length).toBeGreaterThan(0);
// SEP-2351: discovery uses stable well-known suffixes (not only "some oauth URL").
const trackedUrls = tracker.map((c) => c.url);
expect(
trackedUrls.some((u) =>
u.includes("/.well-known/oauth-protected-resource"),
),
).toBe(true);
expect(
trackedUrls.some((u) =>
u.includes("/.well-known/oauth-authorization-server"),
),
).toBe(true);
// Verify fetch tracking categories: auth vs transport
const fetchRequests = fetchRequestLogState.getFetchRequests();
const authFetches = fetchRequests.filter((r) => r.category === "auth");
const transportFetches = fetchRequests.filter(
(r) => r.category === "transport",
);
expect(authFetches.length).toBeGreaterThan(0);
expect(transportFetches.length).toBeGreaterThan(0);
});
});
describe("SEP-2468 iss validation (Streamable HTTP)", () => {
const transport = transports[1]!;
async function startStaticClient() {
const staticClientId = "test-iss-client";
const staticClientSecret = "test-iss-secret";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
},
},
);
return { serverUrl, oauthConfig };
}
it("accepts completeOAuthFlow with matching callback iss", async () => {
await startStaticClient();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code, iss } = await completeOAuthAuthorization(authUrl);
expect(iss).toBeDefined();
await client.completeOAuthFlow(code, iss);
await client.connect();
expect(client.getStatus()).toBe("connected");
expect(await client.getOAuthTokens()).toBeDefined();
});
it("rejects mismatched callback iss before storing tokens", async () => {
await startStaticClient();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code } = await completeOAuthAuthorization(authUrl);
await expect(
client.completeOAuthFlow(code, "https://evil.example"),
).rejects.toThrow(/Issuer mismatch|issuer/i);
expect(await client.getOAuthTokens()).toBeUndefined();
});
it("rejects missing iss when AS requires authorization_response iss", async () => {
await startStaticClient();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code } = await completeOAuthAuthorization(authUrl);
await expect(client.completeOAuthFlow(code)).rejects.toThrow(
/Issuer mismatch|issuer/i,
);
expect(await client.getOAuthTokens()).toBeUndefined();
});
});
describe("SEP-2207 offline_access on authorize (Streamable HTTP)", () => {
it("requests offline_access when AS scopes_supported includes it", async () => {
const transport = transports[1]!;
const staticClientId = "test-offline-access-client";
const staticClientSecret = "test-offline-access-secret";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
scopesSupported: ["mcp", "offline_access"],
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
scope: "mcp",
});
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
},
},
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const scope = authUrl.searchParams.get("scope") ?? "";
expect(scope.split(/\s+/)).toContain("offline_access");
});
});
describe("SEP-2352 AS migration (Streamable HTTP)", () => {
it("does not silently reuse issuer-A tokens when discovery resolves issuer B", async () => {
const transport = transports[1]!;
const staticClientId = "test-as-migrate-client";
const staticClientSecret = "test-as-migrate-secret";
let asB: TestServerHttp | undefined;
try {
const serverAConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverAConfig);
const portA = await server.start();
const serverAUrl = `http://localhost:${portA}`;
await waitForOAuthWellKnown(serverAUrl);
const serverBConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
asB = new TestServerHttp(serverBConfig);
const portB = await asB.start();
const serverBUrl = `http://localhost:${portB}`;
await waitForOAuthWellKnown(serverBUrl);
const issuerB = serverBUrl.replace(/\/$/, "");
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverAUrl}${transport.endpoint}`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
},
},
);
const mcpUrl = `${serverAUrl}${transport.endpoint}`;
const authUrlA = await client.authenticate();
if (!authUrlA) throw new Error("Expected authorization URL for A");
expect(authUrlA.origin).toBe(new URL(serverAUrl).origin);
const { code, iss } = await completeOAuthAuthorization(authUrlA);
await client.completeOAuthFlow(code, iss);
await client.connect();
expect(client.getStatus()).toBe("connected");
expect(await client.getOAuthTokens()).toBeDefined();
await client.disconnect();
// SDK auth() reuses cached authorizationServerUrl and will not pick up a
// PRM flip until discovery is replaced. Simulate AS migration by pointing
// persisted discovery at issuer B while leaving issuer-A tokens in place.
const asBMetadata = await discoverAuthorizationServerMetadata(issuerB);
await oauthConfig.storage.saveDiscoveryState(mcpUrl, {
authorizationServerUrl: issuerB,
authorizationServerMetadata: asBMetadata,
resourceMetadata: {
resource: `${serverAUrl}/`,
authorization_servers: [issuerB],
},
});
// Shared in-memory AS token store would otherwise let B redeem A's
// refresh_token; clear AS-side grants so reuse cannot succeed via refresh.
clearOAuthTestData();
const authUrlB = await client.authenticate();
expect(authUrlB).toBeDefined();
expect(authUrlB!.origin).toBe(new URL(serverBUrl).origin);
} finally {
if (asB) await asB.stop();
}
});
});
});