All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
7 changed files with 11437 additions and 1212 deletions

View file

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

View file

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

View file

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

View file

@ -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",
});
});
});

View file

@ -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({});

View file

@ -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 () => {

View file

@ -7,6 +7,7 @@ import {
loadConfig,
resolveConfig,
ExternalAccessTokenValidator,
extractScopesFromJwtPayload,
TestServerHttp,
createExternalResourceOAuthTestServerConfig,
} from "@modelcontextprotocol/inspector-test-server";
@ -59,6 +60,19 @@ describe("protected-resource OAuth config", () => {
});
describe("ExternalAccessTokenValidator", () => {
it("extracts scopes from JWT payload claims", () => {
expect(extractScopesFromJwtPayload({ scope: "mcp tools:read" })).toEqual([
"mcp",
"tools:read",
]);
expect(
extractScopesFromJwtPayload({ scp: ["tools:read", "env:read"] }),
).toEqual(["tools:read", "env:read"]);
expect(extractScopesFromJwtPayload({ scopes: ["tools:read"] })).toEqual([
"tools:read",
]);
});
it("accepts JWTs signed by the AS and rejects wrong issuer", async () => {
const issuer = "https://as.example";
const jwksUri = "https://as.example/jwks";