inspector/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts
2026-07-02 16:35:31 -07:00

243 lines
7.3 KiB
TypeScript

/**
* 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);
});