Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # AGENTS.md # clients/cli/README.md # clients/web/src/App.test.tsx # clients/web/src/App.tsx # clients/web/src/test/core/auth/utils.test.ts # core/mcp/node/transport.ts # test-servers/src/composable-test-server.ts
This commit is contained in:
commit
cfbdfdf2bd
3 changed files with 15921 additions and 1202 deletions
|
|
@ -17,6 +17,7 @@ npm run test:cli # cli.test.ts
|
|||
npm run test:cli-tools # tools.test.ts
|
||||
npm run test:cli-headers # headers.test.ts
|
||||
npm run test:cli-metadata # metadata.test.ts
|
||||
npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity
|
||||
```
|
||||
|
||||
## How the CLI is exercised
|
||||
|
|
@ -42,7 +43,10 @@ root provides a further end-to-end check of the binary.)
|
|||
- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation
|
||||
- `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator)
|
||||
- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed
|
||||
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot)
|
||||
- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides)
|
||||
- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry)
|
||||
- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e)
|
||||
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth)
|
||||
|
||||
## Helpers
|
||||
|
||||
|
|
|
|||
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
|
||||
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
||||
import {
|
||||
handleCliAuthRecoveryRequired,
|
||||
isStandardOAuthStepUp,
|
||||
runCliInteractiveOAuth,
|
||||
withCliAuthRecoveryRetry,
|
||||
} from "../src/cliOAuth.js";
|
||||
|
||||
describe("cliOAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("isStandardOAuthStepUp", () => {
|
||||
it("returns true for insufficient_scope on non-EMA servers", () => {
|
||||
expect(
|
||||
isStandardOAuthStepUp(
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
{},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for EMA servers", () => {
|
||||
expect(
|
||||
isStandardOAuthStepUp(
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
{ enterpriseManaged: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth writes success to stderr", async () => {
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "success",
|
||||
});
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
await runCliInteractiveOAuth(client, redirectUrlProvider, {
|
||||
hostname: "127.0.0.1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
});
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
|
||||
const challenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
requiredScopes: ["weather:read"],
|
||||
};
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "insufficient_scope",
|
||||
challenge,
|
||||
});
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCliInteractiveOAuth(
|
||||
client,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ authChallenge: challenge },
|
||||
),
|
||||
).rejects.toThrow(/required scopes were not granted/);
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => false,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const authorizationUrl = new URL("https://as.example/authorize");
|
||||
const challenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
requiredScopes: ["weather:read"],
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(authorizationUrl, challenge);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => true,
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl,
|
||||
authChallenge: challenge,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
},
|
||||
);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => {
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "success",
|
||||
});
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "unauthorized",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ enterpriseManaged: true },
|
||||
fn,
|
||||
async () => true,
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
242
clients/cli/__tests__/oauth-interactive.test.ts
Normal file
242
clients/cli/__tests__/oauth-interactive.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
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<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");
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function createAutoCompleteNavigation(
|
||||
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||
) {
|
||||
return new CallbackNavigation(async (url) => {
|
||||
const code = await completeOAuthAuthorization(url);
|
||||
const redirect = redirectUrlProvider.redirectUrl;
|
||||
if (!redirect) {
|
||||
throw new Error("redirectUrl not set");
|
||||
}
|
||||
await fetch(`${redirect}?code=${encodeURIComponent(code)}`);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
Loading…
Reference in a new issue