/** * Interactive OAuth against a real HTTP test server. * * Every test here raises past the cli project's shared 15s budget, and is meant * to: each boots a server, waits for its well-known documents, runs a full * authorization round trip, then disconnects. That is elapsed work, not slack * for a loaded machine, so it stays stated per test rather than folding into * the shared value (#2323). */ import { describe, it, expect, afterEach, vi } from "vitest"; import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { TestServerHttp, waitForOAuthWellKnown, getDefaultServerConfig, createOAuthTestServerConfig, clearOAuthTestData, } from "@modelcontextprotocol/inspector-test-server"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/index.js"; import { CallbackNavigation, MutableRedirectUrlProvider, } from "@inspector/core/auth/index.js"; import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; import { connectInspectorWithOAuth, withCliAuthRecoveryRetry, } from "../src/cliOAuth.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import { makeFakeServerSettings } from "./helpers/oauth-test-fakes.js"; const oauthTestStatePath = join( tmpdir(), `mcp-oauth-${process.pid}-cli-interactive.json`, ); async function completeOAuthAuthorization( authorizationUrl: URL, ): Promise<{ code: string; iss?: string }> { let response = await fetch(authorizationUrl.toString(), { redirect: "manual", }); // Local composable test AS shows an HTML consent page on GET; approve via POST. if (response.status === 200) { const body = new URLSearchParams(authorizationUrl.searchParams); response = await fetch( `${authorizationUrl.origin}${authorizationUrl.pathname}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, redirect: "manual", }, ); } if (response.status !== 302 && response.status !== 301) { throw new Error( `Expected redirect (302/301), got ${response.status}: ${await response.text()}`, ); } const location = response.headers.get("location"); if (!location) { throw new Error("Missing Location header"); } const redirect = new URL(location, authorizationUrl.origin); const code = redirect.searchParams.get("code"); if (!code) { throw new Error("Missing authorization code"); } const iss = redirect.searchParams.get("iss") ?? undefined; return iss ? { code, iss } : { code }; } function createAutoCompleteNavigation( redirectUrlProvider: MutableRedirectUrlProvider, ) { return new CallbackNavigation(async (url) => { const { code, iss } = await completeOAuthAuthorization(url); const redirect = redirectUrlProvider.redirectUrl; if (!redirect) { throw new Error("redirectUrl not set"); } const callback = new URL(redirect); callback.searchParams.set("code", code); if (iss) callback.searchParams.set("iss", iss); await fetch(callback.href); }); } const callbackUrlConfig = { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback", }; const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback"; /** A full interactive OAuth round trip against a freshly booted server. */ const OAUTH_ROUND_TRIP_MS = 30_000; describe("CLI interactive OAuth (integration)", () => { let mcpServer: TestServerHttp | null = null; afterEach(async () => { if (mcpServer) { await mcpServer.stop(); mcpServer = null; } clearOAuthTestData(); try { rmSync(oauthTestStatePath, { force: true }); } catch { // ignore } // No budget: stopping the server and unlinking a file is not the round trip // this file's constant describes, and the shared `hookTimeout` is already // 30000 — so a per-hook argument here would restate it and say the wrong // thing about it (Copilot). }); it( "connects to an OAuth-protected server via the loopback callback server", async () => { const serverConfig = { ...getDefaultServerConfig(), serverType: "streamable-http" as const, ...createOAuthTestServerConfig({ requireAuth: true, supportDCR: true, }), }; mcpServer = new TestServerHttp(serverConfig); const port = await mcpServer.start(); const serverUrl = `http://localhost:${port}`; await waitForOAuthWellKnown(serverUrl); const redirectUrlProvider = new MutableRedirectUrlProvider(); redirectUrlProvider.redirectUrl = presetRedirectUrl; const navigation = createAutoCompleteNavigation(redirectUrlProvider); const client = new InspectorClient( { type: "streamable-http", url: `${serverUrl}/mcp`, } as MCPServerConfig, { environment: { transport: createTransportNode, oauth: { storage: new NodeOAuthStorage(oauthTestStatePath), navigation, redirectUrlProvider, }, }, directAuthRecovery: true, oauth: {}, }, ); await connectInspectorWithOAuth( client, { type: "streamable-http", url: `${serverUrl}/mcp` }, redirectUrlProvider, callbackUrlConfig, undefined, { isTTY: true }, ); const tools = await client.listTools(); expect(tools.tools.length).toBeGreaterThan(0); await client.disconnect(); }, OAUTH_ROUND_TRIP_MS, ); it( "retries an RPC after step-up authorization when the user confirms", async () => { 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, supportDCR: true, }), }; mcpServer = new TestServerHttp(serverConfig); const port = await mcpServer.start(); const serverUrl = `http://localhost:${port}`; await waitForOAuthWellKnown(serverUrl); const redirectUrlProvider = new MutableRedirectUrlProvider(); redirectUrlProvider.redirectUrl = presetRedirectUrl; const navigation = createAutoCompleteNavigation(redirectUrlProvider); const client = new InspectorClient( { type: "streamable-http", url: `${serverUrl}/mcp`, } as MCPServerConfig, { environment: { transport: createTransportNode, oauth: { storage: new NodeOAuthStorage(oauthTestStatePath), navigation, redirectUrlProvider, }, }, directAuthRecovery: true, oauth: { scope: "mcp tools:read", }, }, ); await connectInspectorWithOAuth( client, { type: "streamable-http", url: `${serverUrl}/mcp` }, redirectUrlProvider, callbackUrlConfig, undefined, { isTTY: true }, ); const tools = await client.listTools(); const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); expect(getTempTool).toBeDefined(); const stderrSpy = vi .spyOn(process.stderr, "write") .mockImplementation(() => true); try { const result = await withCliAuthRecoveryRetry( client, { type: "streamable-http", url: `${serverUrl}/mcp` }, redirectUrlProvider, callbackUrlConfig, makeFakeServerSettings(), () => client.callTool(getTempTool!, { city: "NYC", units: "C", }), { confirmStepUp: async () => true, isTTY: true }, ); const stepUpPrompted = stderrSpy.mock.calls.some( ([chunk]) => typeof chunk === "string" && chunk.includes("Proceed with step-up authorization?"), ); expect(stepUpPrompted).toBe(true); expect(result.success).toBe(true); } finally { stderrSpy.mockRestore(); await client.disconnect(); } }, OAUTH_ROUND_TRIP_MS, ); });