inspector/clients/cli/__tests__/oauth-interactive.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

246 lines
7.1 KiB
TypeScript

import { describe, it, expect, afterEach } 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";
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";
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
}
}, 30_000);
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,
);
const tools = await client.listTools();
expect(tools.tools.length).toBeGreaterThan(0);
await client.disconnect();
}, 30_000);
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,
);
const tools = await client.listTools();
const getTempTool = tools.tools.find((tool) => tool.name === "get_temp");
expect(getTempTool).toBeDefined();
let stepUpPrompted = false;
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk, ...rest) => {
const text = typeof chunk === "string" ? chunk : String(chunk);
if (text.includes("Proceed with step-up authorization?")) {
stepUpPrompted = true;
}
return originalWrite(chunk, ...rest);
}) as typeof process.stderr.write;
try {
const result = await withCliAuthRecoveryRetry(
client,
redirectUrlProvider,
callbackUrlConfig,
{},
() =>
client.callTool(getTempTool!, {
city: "NYC",
units: "C",
}),
async () => true,
);
expect(stepUpPrompted).toBe(true);
expect(result.success).toBe(true);
} finally {
process.stderr.write = originalWrite;
await client.disconnect();
}
}, 30_000);
});