Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The Inspector ships as one package with three clients (**Web**, **CLI**, **TUI**
- **Avoid double casts (`as unknown as T`).** They erase all type safety and usually mean the real type is being worked around. Prefer a type guard, a narrower single cast, or fixing the underlying type. If genuinely unavoidable (a documented gap in a third-party type, or bridging structurally-identical shapes TS can't relate), it **must** carry an inline comment justifying why it's safe and why nothing better exists. An unjustified `as unknown as` is not acceptable in review.
- Prefer inference, type guards, and precise annotations over assertions.
- An `_` prefix is the intentionally-unused marker (`argsIgnorePattern` / `varsIgnorePattern` / `caughtErrorsIgnorePattern`).
- **Never float a promise.** `@typescript-eslint/no-floating-promises` is `error` in all five ESLint scopes, so every promise must be awaited, `.catch(…)`-terminated, returned, or explicitly discarded with `void`. Prefer **holding and settling** it — a floated rejection surfaces in an unrelated file as an SDK-internal stack, which is how two un-held `callTool` calls failed the whole `npm run ci` chain (#1947). Reach for `void` only when the callee already owns its failures (it ends in a `catch`) or the caller genuinely cannot await (a sync effect body, an Ink key handler), and say which in a comment. In Storybook play functions, `expect(...)` from `storybook/test` is instrumented and returns a promise — it must be awaited.

## React and UI (web client)

Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab
- Use type guards and type assertions to handle potential type mismatches and ensure type safety
- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
- **NEVER leave a promise floating.** `@typescript-eslint/no-floating-promises` is enabled at `error` in **all five** ESLint scopes — `clients/{web,cli,tui,launcher}` and the root `core/` + shared gate (#1959). Every promise must be awaited, returned, terminated with `.catch(…)`, or explicitly discarded with the `void` operator.
- **The class it catches is invisible at review time.** A floated call reads like an awaited one minus four characters, and the unhandled rejection it produces surfaces somewhere else entirely — a different test, a different file, a stack pointing at SDK internals. Two un-held `client.callTool(...)` promises made `npm run ci` unpassable in #1947: `disconnect()` rejected them with `Connection closed`, the unhandled rejection failed the whole vitest run, and the chain aborted at `coverage`, silently skipping `verify:build-gate`, `smoke`, and Storybook. Attributing it took a full investigation; the fix was two lines.
- **Prefer holding and settling the promise.** `void` is an escape hatch, not a fix — it is visible at review time (strictly better than nothing) but still discards the rejection. Reach for it only when the callee already owns its failures (it ends in a `catch` that surfaces the message) or the caller genuinely cannot await — a synchronous `useEffect` body, an Ink `useInput` key handler, a Hono `stream.onAbort` listener. Say **which** of those it is in a one-line comment; an unexplained `void` is a review finding. Where the callee does *not* own its failures, give it a `catch` rather than voiding the call (see `handleDisconnect` in `clients/tui/src/App.tsx`), or terminate with `.catch(…)` at the call site (see `open(url)` in `clients/web/server/{server,vite-hono-plugin}.ts`).
- **In Storybook play functions, `expect(...)` from `storybook/test` returns a promise.** Storybook instruments it so the interactions panel can trace each assertion, so every `expect` in a play function is awaited — as is any shared helper that wraps one (`src/test/scrollAreaStoryAssertions.ts` is `async` for this reason).
- **The rule is type-aware, so each scope's ESLint config carries a parser project.** The four clients point at their own tsconfig projects (both of them, since `src` and `__tests__`/`server` live in different ones); the root config points at **`tsconfig.lint.json`**, which exists solely to give the parser a program covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts` — none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type *checking* for those sources stays where it was (`core/` through `clients/web`'s `tsc -b`, `test-servers/src` through `clients/cli`'s test project). It sets `moduleResolution: bundler` deliberately — `core/` uses extensionless relative imports, which NodeNext fails to resolve, degrading every import to `any` so the rule silently stops seeing promises at all. **When adding a first-party TS location to the root lint scope, add it to `tsconfig.lint.json`'s `include` too**, or the parser fails it outright with "was not found in any of the provided project(s)". Note also that a tsconfig `include` does **not** expand braces — `core/**/*.{ts,tsx}` matches nothing; list the extensions separately.
- Type-aware linting costs real time: `clients/web`'s `eslint .` went from ~8s to ~19s, and `lint` runs inside `validate`, the fast inner-loop check. That is the price of the guarantee; if it needs reducing later, narrowing the projects each scope loads is the lever, not dropping the rule.

## Web source layout: `src/lib` vs `src/utils`

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ Each client self-validates from its own folder; the root scripts chain them. The

Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.

**Linting is type-aware.** All five ESLint scopes (`clients/{web,cli,tui,launcher}` plus the root `core/` + shared gate) enable `@typescript-eslint/no-floating-promises` at `error`, so a promise that is neither awaited, returned, `.catch(…)`-terminated, nor explicitly discarded with `void` fails `lint` — and therefore `validate` ([#1959](https://github.com/modelcontextprotocol/inspector/issues/1959)). The rule needs type information, so each scope's config names a parser project; the root scope's is **`tsconfig.lint.json`**, a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, which have no tsconfig of their own. It emits nothing and changes no typecheck — but a new first-party TS location added to the root lint scope must be added to its `include`. See **TypeScript instructions** in [`AGENTS.md`](./AGENTS.md) for when `void` is acceptable.

For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md).

## Publishing
Expand Down
18 changes: 18 additions & 0 deletions clients/cli/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,22 @@ export default defineConfig([
globals: globals.node,
},
},
{
// Type-aware pass for `no-floating-promises` (#1959). The rule needs type
// information, which `tseslint.configs.recommended` does not provide, and
// the parser needs a project that literally contains the linted file — so
// both of this client's tsconfig projects are listed, exactly as
// `npm run typecheck` runs them (`src` is in the first, `__tests__` only
// in the second).
files: ["**/*.ts"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.test.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
]);
15 changes: 15 additions & 0 deletions clients/launcher/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,19 @@ export default defineConfig([
globals: globals.node,
},
},
{
// Type-aware pass for `no-floating-promises` (#1959) — see the CLI config
// for the reasoning. Both tsconfig projects are listed because `src` and
// `__tests__` live in different ones.
files: ["**/*.{ts,mts}"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.test.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
]);
36 changes: 36 additions & 0 deletions clients/tui/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,42 @@ describe("App (foundation)", () => {
expect(h.disconnect).toHaveBeenCalled();
});

it("surfaces a disconnect failure instead of floating the rejection", async () => {
// 'd' is a key handler, so it cannot await handleDisconnect — the handler
// has to own the failure itself or it escapes as an unhandled rejection
// and fails the whole run from somewhere else (#1959).
// The banner is deliberately independent of connection status: a rejected
// disconnect leaves the status "connected", so anything gated on
// `status === "error"` would never be seen.
h.ctrl.status = "connected";
h.disconnect.mockRejectedValue(new Error("discfail"));
const r = await mount(oneStdio());
r.stdin.write("d");
await expectFrame(r, "Disconnect failed: discfail");
});

it("owns a non-Error disconnect rejection too", async () => {
// The catch stringifies a non-Error rejection rather than reading
// `.message` off it; a throw here would escape the same way.
h.ctrl.status = "connected";
h.disconnect.mockRejectedValue("plainstring");
const r = await mount(oneStdio());
r.stdin.write("d");
await expectFrame(r, "Disconnect failed: plainstring");
});

it("clears a disconnect failure once a retry succeeds", async () => {
// A stale banner would keep reporting a failure the user has since fixed.
h.ctrl.status = "connected";
h.disconnect.mockRejectedValueOnce(new Error("discfail"));
const r = await mount(oneStdio());
r.stdin.write("d");
await expectFrame(r, "Disconnect failed: discfail");
r.stdin.write("d");
await waitUntil(() => !(r.lastFrame() ?? "").includes("Disconnect failed"));
expect(r.lastFrame() ?? "").not.toContain("Disconnect failed");
});

it("switches tabs via accelerator keys", async () => {
const r = await mount(stdioServer());
await press(r, ["t"]); // tools tab (server is auto-selected)
Expand Down
4 changes: 3 additions & 1 deletion clients/tui/__tests__/tsupConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ describe("tui tsup config", () => {
// the double cast is bridging a real structural gap, not hiding a mismatch.
// A hook the patch called but the stub lacks fails as undefined here rather
// than passing silently.
inkFormLabelPatch.setup(
// esbuild types `setup` as `void | Promise<void>`; await it so the
// assertions below can't run before an async setup has registered.
await inkFormLabelPatch.setup(
build as unknown as Parameters<typeof inkFormLabelPatch.setup>[0],
);

Expand Down
15 changes: 15 additions & 0 deletions clients/tui/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,19 @@ export default defineConfig([
"react-hooks/exhaustive-deps": "warn",
},
},
{
// Type-aware pass for `no-floating-promises` (#1959) — see the CLI config
// for the reasoning. Both tsconfig projects are listed because `src` and
// `__tests__` live in different ones.
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.test.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
]);
38 changes: 34 additions & 4 deletions clients/tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ function App({
pendingStepUpRef.current = pendingStepUp;
}, [pendingStepUp]);
const [connectError, setConnectError] = useState<string | null>(null);
// A failed disconnect is deliberately NOT folded into `connectError`. That
// one is only rendered by InfoTab when the status is "error", which a
// rejected disconnect leaves untouched (the status stays "connected"), so
// the message would never be seen — and it feeds
// `connectError ?? inspectorLastError`, where a stale value would go on
// masking a later, real connection error. This renders in the header
// regardless of status and is cleared on the next connect/disconnect
// attempt and on a server switch.
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const oauthInProgressRef = useRef(false);
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
const selectedServerRef = useRef<string | null>(null);
Expand Down Expand Up @@ -434,6 +443,9 @@ function App({
useEffect(() => {
setOauthStatus("idle");
setOauthMessage(null);
// The header banner is server-scoped, so a failure from the server we just
// left must not be read as this one's.
setDisconnectError(null);
const stepUp = pendingStepUpRef.current;
if (stepUp && selectedServer && stepUp.serverName !== selectedServer) {
setPendingStepUp(null);
Expand Down Expand Up @@ -757,6 +769,8 @@ function App({
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
return;
}
// A connect attempt supersedes whatever the last disconnect reported.
setDisconnectError(null);

const finishConnect = async () => {
await connectInspector();
Expand Down Expand Up @@ -887,8 +901,16 @@ function App({
// Disconnect handler
const handleDisconnect = useCallback(async () => {
if (!selectedServer) return;
await disconnectInspector();
// InspectorClient will update status automatically, and data is preserved
// Clear first, so a retry that succeeds leaves no stale message behind.
setDisconnectError(null);
try {
await disconnectInspector();
// InspectorClient will update status automatically, and data is preserved
} catch (err) {
// Nothing above this catches: the only caller is the key handler, which
// cannot await, so without this the rejection escapes unhandled.
setDisconnectError(err instanceof Error ? err.message : String(err));
}
}, [selectedServer, disconnectInspector]);

const handleClearOAuth = useCallback(async () => {
Expand Down Expand Up @@ -1428,12 +1450,15 @@ function App({
input.toLowerCase() === "c" &&
(inspectorStatus === "disconnected" || inspectorStatus === "error")
) {
handleConnect();
// Both handlers own their failures internally (each ends in a catch
// that surfaces the message), so there is nothing left for a key
// handler — which cannot await — to do with the promise.
void handleConnect();
} else if (
input.toLowerCase() === "d" &&
(inspectorStatus === "connected" || inspectorStatus === "connecting")
) {
handleDisconnect();
void handleDisconnect();
}
}
});
Expand Down Expand Up @@ -1617,6 +1642,11 @@ function App({
<Text color="red">OAuth: {oauthMessage}</Text>
</Box>
)}
{disconnectError && (
<Box marginTop={1}>
<Text color="red">Disconnect failed: {disconnectError}</Text>
</Box>
)}
</Box>
</Box>

Expand Down
6 changes: 4 additions & 2 deletions clients/tui/src/components/PromptsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export function PromptsTab({
if (selectedPrompt.arguments && selectedPrompt.arguments.length > 0) {
onFetchPrompt(selectedPrompt);
} else {
// No arguments, fetch directly
(async () => {
// No arguments, fetch directly. The IIFE catches everything it can
// throw, so there is no rejection for this key handler — which
// cannot await — to own.
void (async () => {
try {
const invocation = await inspectorClient.getPrompt(
selectedPrompt.name,
Expand Down
4 changes: 3 additions & 1 deletion clients/tui/src/components/ResourcesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ export function ResourcesTab({
}
};

fetchContent();
// An effect body is synchronous, and fetchContent already catches
// everything it can throw, so there is no rejection left to own.
void fetchContent();
}, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]);

const listWidth = Math.floor(width * 0.4);
Expand Down
27 changes: 27 additions & 0 deletions clients/web/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,32 @@ export default defineConfig([
"react-refresh/only-export-components": "off",
},
},
{
// Type-aware pass for `no-floating-promises` (#1959). The rule needs type
// information, which `tseslint.configs.recommended` does not provide, and
// the parser needs a project that literally contains the linted file — so
// all four leaf projects are listed (`tsconfig.json` itself is a
// solution file with `files: []`, so it contains nothing).
//
// `.d.ts` is excluded: `vitest.shims.d.ts` is pulled in through a `types`
// reference rather than an `include`, so no project owns it, and a
// declaration file cannot float a promise anyway.
files: ["**/*.{ts,tsx}"],
ignores: ["**/*.d.ts"],
languageOptions: {
parserOptions: {
project: [
"./tsconfig.app.json",
"./tsconfig.node.json",
"./tsconfig.storybook.json",
"./tsconfig.test.json",
],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
},
},
...storybook.configs["flat/recommended"],
]);
8 changes: 7 additions & 1 deletion clients/web/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,13 @@ export async function startHonoServer(
sandboxUrl ?? undefined,
);
if (config.autoOpen) {
open(url);
// A headless or otherwise browser-less host rejects here. The server
// is already listening and the banner above printed the URL, so warn
// and carry on rather than letting the rejection escape this
// synchronous listen callback and take the process down.
open(url).catch((err: unknown) => {
console.warn("Could not open a browser automatically:", err);
});
}
},
);
Expand Down
7 changes: 6 additions & 1 deletion clients/web/server/vite-hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
);

if (config.autoOpen) {
open(url);
// Same as the prod server: a browser-less host rejects, the dev
// server is already up, and the banner above printed the URL — so
// warn rather than let the rejection escape this sync helper.
open(url).catch((err: unknown) => {
console.warn("Could not open a browser automatically:", err);
});
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ export const Transport: Story = {
args: { category: "transport" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("transport")).toBeInTheDocument();
await expect(canvas.getByText("transport")).toBeInTheDocument();
},
};

export const Auth: Story = {
args: { category: "auth" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("auth")).toBeInTheDocument();
await expect(canvas.getByText("auth")).toBeInTheDocument();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const FullSize: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};

Expand All @@ -38,6 +38,6 @@ export const Embedded: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};
Loading