Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # clients/cli/package-lock.json # clients/cli/package.json # clients/web/src/App.tsx # clients/web/src/lib/environmentFactory.test.ts # clients/web/src/lib/environmentFactory.ts # clients/web/src/lib/remoteOAuthStorage.test.ts # clients/web/src/lib/remoteOAuthStorage.ts # clients/web/src/test/core/auth/connection-state.test.ts # clients/web/src/test/core/auth/providers.test.ts # clients/web/src/test/core/auth/storage-browser.test.ts # clients/web/src/test/core/auth/storage-remote.test.ts # clients/web/src/test/core/mcp/oauthManager.test.ts # clients/web/src/test/integration/auth/node/storage.test.ts # clients/web/src/test/integration/storage/adapters.test.ts # clients/web/src/utils/clearServerOAuthState.test.ts # clients/web/src/utils/clearServerOAuthState.ts # core/auth/connection-state.ts # core/auth/oauth-storage.ts # core/auth/providers.ts # core/auth/storage.ts # core/auth/store.ts # core/storage/adapters/remote-storage.ts
This commit is contained in:
commit
32a3610737
4 changed files with 5046 additions and 3271 deletions
|
|
@ -3,15 +3,39 @@ 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 {
|
||||
connectInspectorWithOAuth,
|
||||
handleCliAuthRecoveryRequired,
|
||||
isStandardOAuthStepUp,
|
||||
runCliInteractiveOAuth,
|
||||
withCliAuthRecoveryRetry,
|
||||
} from "../src/cliOAuth.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from
|
||||
// stdin via node:readline/promises. Mock the module so the default path can be
|
||||
// exercised deterministically without real TTY input.
|
||||
const { mockQuestion, mockClose } = vi.hoisted(() => ({
|
||||
mockQuestion: vi.fn(),
|
||||
mockClose: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: mockQuestion,
|
||||
close: mockClose,
|
||||
})),
|
||||
}));
|
||||
|
||||
const CALLBACK_URL_CONFIG = {
|
||||
hostname: "127.0.0.1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
};
|
||||
|
||||
describe("cliOAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockQuestion.mockReset();
|
||||
mockClose.mockReset();
|
||||
});
|
||||
|
||||
describe("isStandardOAuthStepUp", () => {
|
||||
|
|
@ -227,4 +251,207 @@ describe("cliOAuth", () => {
|
|||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("confirmStepUpFromStdin (default stdin confirmer)", () => {
|
||||
const standardStepUpError = () =>
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
});
|
||||
|
||||
const clientNeedingStepUp = () => ({
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||
});
|
||||
|
||||
it("proceeds with OAuth when the user answers y (no confirmStepUp arg)", async () => {
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
|
||||
// Omitting the confirmStepUp argument exercises the default
|
||||
// confirmStepUpFromStdin, which reads from the mocked readline interface.
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalled();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a whitespace-padded, upper-case 'YES'", async () => {
|
||||
mockQuestion.mockResolvedValue(" YES ");
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines (throws) when the user answers n", async () => {
|
||||
mockQuestion.mockResolvedValue("n");
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectInspectorWithOAuth recovery branch", () => {
|
||||
const oauthServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: "https://as.example/mcp",
|
||||
} as MCPServerConfig;
|
||||
|
||||
it("resumes without re-auth when storage already satisfies the challenge", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
);
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs interactive recovery when storage does not satisfy the challenge", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
);
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs interactive OAuth on a plain unauthorized error (disconnect failure is swallowed)", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Connection failed for server (401)"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
// A rejecting disconnect exercises the `.catch(() => {})` guard.
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn().mockRejectedValue(new Error("disconnect failed")),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
);
|
||||
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rethrows a non-OAuth error unchanged", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("some unrelated failure"));
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
),
|
||||
).rejects.toThrow("some unrelated failure");
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows when the server config is not OAuth-capable", async () => {
|
||||
const connect = vi.fn().mockRejectedValue(new Error("nope (401)"));
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
client,
|
||||
{ type: "stdio", command: "x" } as MCPServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
),
|
||||
).rejects.toThrow("nope (401)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
32
clients/cli/package-lock.json
generated
32
clients/cli/package-lock.json
generated
|
|
@ -16,8 +16,7 @@
|
|||
"commander": "^13.1.0",
|
||||
"pino": "^9.14.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-inspector-cli": "build/index.js"
|
||||
|
|
@ -5685,35 +5684,6 @@
|
|||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@
|
|||
"commander": "^13.1.0",
|
||||
"pino": "^9.14.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default defineConfig({
|
|||
platform: 'node',
|
||||
// Bundle core source; leave npm deps external.
|
||||
noExternal: [/^@inspector\/core/],
|
||||
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'],
|
||||
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino'],
|
||||
esbuildOptions(options) {
|
||||
options.alias = {
|
||||
'@inspector/core': path.join(repoRoot, 'core'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue