Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
.claude/
node_modules
dist
test-servers/build
.env
/.playwright-mcp/
14 changes: 11 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ inspector/
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File and remote storage adapters (Zustand middleware)
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
│ # Aliased as `@modelcontextprotocol/inspector-test-server`
│ # in clients/web/vite.config.ts and tsconfig.test.json.
│ ├── src/ # TypeScript sources.
│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build`
│ │ # so integration tests can spawn the stdio server as a real
│ │ # subprocess via `node test-servers/build/test-server-stdio.js`.
│ └── tsconfig.json # tsc build config (NodeNext, outDir ./build).
│ # The Vite alias `@modelcontextprotocol/inspector-test-server`
│ # in clients/web/vite.config.ts points at build/index.js
│ # (not src/) so `getTestMcpServerPath()` returns a `.js` path.
│ # tsconfig.test.json keeps paths pointing at src for typecheck.
├── specification/ # Build specification
...
```
Expand Down Expand Up @@ -82,7 +89,8 @@ All work should be driven by items on the project board.
- In unit tests that expect error output, suppress it from the console
- Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/`
- Run `npm run test:coverage` to verify the per-file gate: lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50 (CI enforces this gate). Branches is intentionally relaxed because Mantine portal/media-query branches are not exercisable under happy-dom; new business-logic branches should still be covered.
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules)
- Run `npm run test:integration` (also from `clients/web/`) for the v1.5-ported InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests.
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). v1.5-ported integration tests live under `clients/web/src/test/core/` and are wired into the `integration` project via the `integrationTests` list in `vite.config.ts`.
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme

### Responding to Code Reviews
Expand Down
7 changes: 5 additions & 2 deletions clients/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
"build-storybook": "storybook build",
"test": "vitest run --project=unit",
"test:watch": "vitest --project=unit",
"test:coverage": "vitest run --project=unit --coverage",
"test:coverage": "npm run test-servers:build && vitest run --project=unit --project=integration --coverage",
"test:storybook": "vitest run --project=storybook",
"test:storybook:watch": "vitest --project=storybook"
"test:storybook:watch": "vitest --project=storybook",
"test-servers:build": "tsc -p ../../test-servers --noCheck",
"test:integration": "npm run test-servers:build && vitest run --project=integration",
"test:integration:watch": "npm run test-servers:build && vitest --project=integration"
},
"dependencies": {
"@emotion/react": "^11.14.0",
Expand Down
44 changes: 44 additions & 0 deletions clients/web/src/test/core/auth/oauth-callback-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,50 @@ describe("OAuthCallbackServer", () => {
).rejects.toThrow();
});

it("succeeds with 200 when no onCallback handler is configured", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0 });

const res = await fetch(
`http://localhost:${result.port}/oauth/callback?code=lonely`,
);

expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("OAuth complete");
});

it("returns 409 when a callback arrives after handled=true but before stop completes", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});

server = createOAuthCallbackServer();
const result = await server.start({
port: 0,
// Hold onCallback open so handled=true is set but the server hasn't
// yet finished stopping — second request slips into the 409 branch.
onCallback: async () => {
await gate;
},
});

const firstP = fetch(
`http://localhost:${result.port}/oauth/callback?code=first`,
);
// Give the server time to register handled=true before second hit.
await new Promise((r) => setTimeout(r, 50));
const secondP = fetch(
`http://localhost:${result.port}/oauth/callback?code=second`,
);
const second = await secondP;
expect(second.status).toBe(409);
release();
const first = await firstP;
expect(first.status).toBe(200);
});

it("onCallback rejection returns 500 and error HTML", async () => {
server = createOAuthCallbackServer();
const result = await server.start({
Expand Down
69 changes: 68 additions & 1 deletion clients/web/src/test/core/auth/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
ConsoleNavigation,
CallbackNavigation,
} from "@inspector/core/auth/providers.js";
import { BrowserNavigation } from "@inspector/core/auth/browser/providers.js";
import {
BrowserNavigation,
BrowserOAuthClientProvider,
} from "@inspector/core/auth/browser/providers.js";

describe("OAuthNavigation", () => {
describe("ConsoleNavigation", () => {
Expand Down Expand Up @@ -77,4 +80,68 @@ describe("OAuthNavigation", () => {
);
});
});

describe("BrowserOAuthClientProvider", () => {
// Cast through unknown so we can install a minimal { location } stub
// without needing the full Window surface in tests.
type GlobalWithWindow = typeof globalThis & {
window?: unknown;
sessionStorage?: Storage;
};
const originalWindow = (global as GlobalWithWindow).window;
const originalSessionStorage = (global as GlobalWithWindow).sessionStorage;

class MemorySessionStorage implements Storage {
private map = new Map<string, string>();
get length() {
return this.map.size;
}
key(i: number) {
return [...this.map.keys()][i] ?? null;
}
getItem(k: string) {
return this.map.get(k) ?? null;
}
setItem(k: string, v: string) {
this.map.set(k, v);
}
removeItem(k: string) {
this.map.delete(k);
}
clear() {
this.map.clear();
}
}

beforeEach(() => {
// Cast through `unknown` so we can install a minimal { location } stub
// without needing the full Window surface in tests.
(global as unknown as { window?: unknown }).window = {
location: {
origin: "http://localhost:5173",
href: "http://localhost:5173",
},
};
(global as GlobalWithWindow).sessionStorage = new MemorySessionStorage();
});

afterEach(() => {
(global as unknown as { window?: unknown }).window = originalWindow;
(global as GlobalWithWindow).sessionStorage = originalSessionStorage;
});

it("constructs and exposes redirectUrl derived from window.location.origin", () => {
const provider = new BrowserOAuthClientProvider(
"https://mcp.example.com",
);
expect(provider.redirectUrl).toBe("http://localhost:5173/oauth/callback");
});

it("throws if window is undefined", () => {
(global as unknown as { window?: unknown }).window = undefined;
expect(
() => new BrowserOAuthClientProvider("https://mcp.example.com"),
).toThrow(/requires browser environment/);
});
});
});
67 changes: 67 additions & 0 deletions clients/web/src/test/core/auth/state-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,5 +370,72 @@ describe("OAuthStateMachine", () => {
}),
);
});

it("token_request execute throws when client information cannot be obtained", async () => {
const metadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
const providerNoClient = {
...mockProvider,
getServerMetadata: vi.fn(() => metadata),
clientInformation: vi.fn(async () => undefined),
} as unknown as BaseOAuthClientProvider;

const tokenState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
authorizationCode: "code-without-client",
};

await expect(
oauthTransitions.token_request.execute({
state: tokenState,
serverUrl: "http://localhost:3000",
provider: providerNoClient,
updateState,
}),
).rejects.toThrow("Client information not available for token exchange");
});

it("complete.canTransition always returns false (terminal state)", async () => {
const result = await oauthTransitions.complete.canTransition({
state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
});
expect(result).toBe(false);
// execute is a no-op
await expect(
oauthTransitions.complete.execute({
state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
}),
).resolves.toBeUndefined();
});

it("executeStep throws when the current step cannot transition", async () => {
// metadata_discovery.canTransition is unconditional (returns true), but
// token_request requires authorizationCode + metadata + clientInfo; an
// empty state will refuse to transition.
const stateMachine = new OAuthStateMachine(
"http://localhost:3000",
mockProvider,
updateState,
);
const blockedState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
oauthStep: "token_request",
};
await expect(stateMachine.executeStep(blockedState)).rejects.toThrow(
/Cannot transition from token_request/,
);
});
});
});
63 changes: 63 additions & 0 deletions clients/web/src/test/core/auth/storage-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,69 @@ describe("BrowserOAuthStorage", () => {
});
});

describe("clearClientInformation", () => {
it("removes the dynamically-registered client info by default", async () => {
storage.saveClientInformation(testServerUrl, { client_id: "dyn" });
expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn",
});
storage.clearClientInformation(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
});

it("removes the preregistered client info when isPreregistered=true", async () => {
storage.savePreregisteredClientInformation(testServerUrl, {
client_id: "pre",
});
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
client_id: "pre",
});
storage.clearClientInformation(testServerUrl, true);
expect(
await storage.getClientInformation(testServerUrl, true),
).toBeUndefined();
});
});

describe("individual clear methods", () => {
it("clearTokens removes only tokens", async () => {
storage.saveTokens(testServerUrl, {
access_token: "t",
token_type: "Bearer",
});
expect(await storage.getTokens(testServerUrl)).toBeDefined();
storage.clearTokens(testServerUrl);
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
});

it("clearCodeVerifier removes only the PKCE verifier", async () => {
storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
});

it("clearScope removes only the scope", async () => {
storage.saveScope(testServerUrl, "read");
expect(storage.getScope(testServerUrl)).toBe("read");
storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined();
});

it("clearServerMetadata removes only the cached metadata", async () => {
const metadata: OAuthMetadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
});
});

describe("clearServerState", () => {
it("should clear all state for a server", async () => {
const clientInfo: OAuthClientInformation = {
Expand Down
Loading
Loading