The inline finding was a real bug: main() never called the discovery check, so the deny-by-default claim rested on test:scripts happening to run the same comparison — a different command, which `npm run verify:test-timeouts` does not invoke. `failures` is now seeded from it, and a probe config under clients/ was observed to fail the guard. Three suppressed findings, all correct. The afterEach in oauth-interactive restated the shared hookTimeout and labelled it as a round trip, when it only stops a server and unlinks a file; it now carries no budget. The file-lock constants were named for the opposite of what they bound — 90s covers retries running out against a holder that never yields, 60s covers the stale-lock takeover that succeeds — so they are LOCK_RETRIES_EXHAUSTED_MS and STALE_LOCK_TAKEOVER_MS. The browser-timeouts header said 15 budgets; it is 19. Separately, and more importantly: the asyncUtilTimeout raise is reverted, on measurement. Verifying round 2 turned up intermittent 15s timeouts across unrelated unit files, so three arms of three full unit runs were taken in this worktree at comparable load — unmodified v2/main 3/3 green, this change with 5000 producing 1, 4 and 0 timing-out files, and the same change with that one line reverted 3/3 green. A wait that is MEANT to expire spends its whole budget on the happy path, so a 5x raise is a 5x cost on exactly those tests and saturates the worker pool, starving tests that were never slow. Both web setup files therefore state asyncUtilTimeout at the library's own value rather than raising it, which is what #2323's rule actually asks for: a budget nobody chose is the defect, not a budget that is small. The tests that assert absence by letting a wait expire are the real problem and are #2335. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp Signed-off-by: cliffhall <cliff@futurescale.com>
275 lines
8.3 KiB
TypeScript
275 lines
8.3 KiB
TypeScript
/**
|
|
* 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,
|
|
);
|
|
});
|