Merge branch 'v2/fix/1919-rfc6570-uri-template-expansion' of https://github.com/modelcontextprotocol/inspector into v2/fix/1919-rfc6570-uri-template-expansion
This commit is contained in:
commit
ef3f5e74d8
71 changed files with 2391 additions and 126 deletions
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -45,7 +45,13 @@ v2/main/
|
|||
│ │ # providers.redirectToAuthorization, the only
|
||||
│ │ # seam that sees the SDK-built authorize URL;
|
||||
│ │ # authorization request only, never the token
|
||||
│ │ # request — #2018)
|
||||
│ │ # request — #2018;
|
||||
│ │ # endpointOverrides.ts per-server
|
||||
│ │ # authorization/token URL overrides — a fetch
|
||||
│ │ # wrapper that rewrites the discovered AS
|
||||
│ │ # metadata document, the one seam SDK v2 routes
|
||||
│ │ # BOTH endpoints through (neither reaches the
|
||||
│ │ # OAuthClientProvider) — #1906)
|
||||
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
||||
│ │ │ # runner-interactive-oauth loopback callback flow)
|
||||
|
|
@ -746,6 +752,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.** Each client's config must name **every leaf project covering its lint surface**, since the parser needs a program that literally *contains* the file — for cli, tui, and launcher that is the two they already typecheck (`tsconfig.json` + `tsconfig.test.json`, `src` in the first and `__tests__` only in the second), and for **web it is four** (`tsconfig.app.json`, `tsconfig.node.json`, `tsconfig.storybook.json`, `tsconfig.test.json`) — web's `tsconfig.json` is a solution file with `files: []`, so naming it alone would contain nothing. Adding a leaf project to a client means adding it here too. The root config instead 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. **Its `include` must stay a superset of the root config's type-aware `files` globs** — a file the lint block matches but the project omits fails outright with "was not found in any of the provided project(s)" rather than being checked, so widening one means widening the other (that is why the `include` carries `test-servers/src/**/*.tsx`, which nothing has produced yet). 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`
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ inspector/
|
|||
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
|
||||
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
|
||||
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
|
||||
│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends)
|
||||
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
|
||||
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
|
||||
│ │ # schema collapse shared by the web and TUI form builders
|
||||
|
|
@ -339,6 +339,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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -706,6 +706,91 @@ 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("drops a disconnect rejection that lands after the user switched servers", async () => {
|
||||
// The stale attempt is the one that usually rejects, so without an
|
||||
// attempt token server alpha's failure would surface in beta's header.
|
||||
h.ctrl.status = "connected";
|
||||
let rejectDisconnect: (err: Error) => void = () => {};
|
||||
h.disconnect.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectDisconnect = reject;
|
||||
}),
|
||||
);
|
||||
const r = await mount(stdioServer());
|
||||
r.stdin.write("d"); // alpha's disconnect starts, and hangs
|
||||
await tick();
|
||||
await press(r, [DOWN]); // switch to beta
|
||||
await expectFrame(r, "beta");
|
||||
rejectDisconnect(new Error("stale-alpha-failure"));
|
||||
// Give the rejection every chance to be published before asserting it
|
||||
// wasn't — a bare tick would pass even with the guard removed.
|
||||
await waitUntil(() =>
|
||||
(r.lastFrame() ?? "").includes("stale-alpha-failure"),
|
||||
);
|
||||
expect(r.lastFrame() ?? "").not.toContain("stale-alpha-failure");
|
||||
});
|
||||
|
||||
it("drops a stale disconnect rejection across an A → B → A round trip", async () => {
|
||||
// The server-name check alone passes here: by the time the rejection
|
||||
// lands, alpha is selected again. Only retiring the attempt token on
|
||||
// every switch catches it.
|
||||
h.ctrl.status = "connected";
|
||||
let rejectDisconnect: (err: Error) => void = () => {};
|
||||
h.disconnect.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectDisconnect = reject;
|
||||
}),
|
||||
);
|
||||
const r = await mount(stdioServer());
|
||||
r.stdin.write("d"); // alpha's disconnect starts, and hangs
|
||||
await tick();
|
||||
await press(r, [DOWN]); // alpha -> beta
|
||||
await expectFrame(r, "b.js");
|
||||
await press(r, [UP]); // beta -> alpha again
|
||||
await expectFrame(r, "s.js");
|
||||
rejectDisconnect(new Error("round-trip-failure"));
|
||||
await waitUntil(() => (r.lastFrame() ?? "").includes("round-trip-failure"));
|
||||
expect(r.lastFrame() ?? "").not.toContain("round-trip-failure");
|
||||
});
|
||||
|
||||
it("switches tabs via accelerator keys", async () => {
|
||||
const r = await mount(stdioServer());
|
||||
await press(r, ["t"]); // tools tab (server is auto-selected)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -170,6 +170,17 @@ function App({
|
|||
pendingStepUpRef.current = pendingStepUp;
|
||||
}, [pendingStepUp]);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
// Monotonic token for in-flight disconnects — see handleDisconnect.
|
||||
const disconnectAttemptRef = useRef(0);
|
||||
// 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);
|
||||
|
|
@ -434,6 +445,13 @@ 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. Clearing is not enough: a switch
|
||||
// away and back (A → B → A) would leave both the attempt token and the
|
||||
// server name matching again, so the stale rejection would land on the
|
||||
// re-selected A. Retiring the token on every switch is what closes that.
|
||||
disconnectAttemptRef.current++;
|
||||
setDisconnectError(null);
|
||||
const stepUp = pendingStepUpRef.current;
|
||||
if (stepUp && selectedServer && stepUp.serverName !== selectedServer) {
|
||||
setPendingStepUp(null);
|
||||
|
|
@ -757,6 +775,10 @@ function App({
|
|||
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
|
||||
return;
|
||||
}
|
||||
// A connect attempt supersedes whatever the last disconnect reported —
|
||||
// including one still in flight, which is what the counter bump retires.
|
||||
disconnectAttemptRef.current++;
|
||||
setDisconnectError(null);
|
||||
|
||||
const finishConnect = async () => {
|
||||
await connectInspector();
|
||||
|
|
@ -887,8 +909,31 @@ function App({
|
|||
// Disconnect handler
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
if (!selectedServer) return;
|
||||
await disconnectInspector();
|
||||
// InspectorClient will update status automatically, and data is preserved
|
||||
// Claim this attempt before awaiting. A disconnect can still be in flight
|
||||
// when the user switches servers or presses 'd' again, and it is the
|
||||
// *stale* one that usually rejects — so the catch below must be able to
|
||||
// tell "my failure" from "one that has since been superseded", or server
|
||||
// A's error lands in server B's header. `handleConnect` bumps the same
|
||||
// counter for the same reason.
|
||||
const attempt = ++disconnectAttemptRef.current;
|
||||
const attemptServer = selectedServer;
|
||||
// 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. A
|
||||
// superseded one is still owned here — just not published.
|
||||
if (
|
||||
disconnectAttemptRef.current !== attempt ||
|
||||
selectedServerRef.current !== attemptServer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDisconnectError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [selectedServer, disconnectInspector]);
|
||||
|
||||
const handleClearOAuth = useCallback(async () => {
|
||||
|
|
@ -1428,12 +1473,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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1617,6 +1665,11 @@ function App({
|
|||
<Text color="red">OAuth: {oauthMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{disconnectError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">Disconnect failed: {disconnectError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { readFileSync } from "node:fs";
|
|||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import open from "open";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { Hono } from "hono";
|
||||
|
|
@ -22,6 +21,7 @@ import type { WebServerConfig } from "./web-server-config.js";
|
|||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
openBrowser,
|
||||
} from "./web-server-config.js";
|
||||
import type { WebServerHandle } from "./types.js";
|
||||
|
||||
|
|
@ -125,7 +125,9 @@ export async function startHonoServer(
|
|||
sandboxUrl ?? undefined,
|
||||
);
|
||||
if (config.autoOpen) {
|
||||
open(url);
|
||||
// Never a bare `open(url)`: this callback is synchronous, so a
|
||||
// rejection would escape it. See openBrowser.
|
||||
openBrowser(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import open from "open";
|
||||
// Direct leaf import — the `core/mcp/remote/node/index.ts` barrel re-exports
|
||||
// from `../constants.js`, which would otherwise drag the broader
|
||||
// `core/mcp/remote/index.ts` barrel (including `createRemoteLogger`, with its
|
||||
|
|
@ -22,6 +21,7 @@ import type { WebServerConfig } from "./web-server-config.js";
|
|||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
openBrowser,
|
||||
} from "./web-server-config.js";
|
||||
|
||||
export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
||||
|
|
@ -114,7 +114,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
);
|
||||
|
||||
if (config.autoOpen) {
|
||||
open(url);
|
||||
// Never a bare `open(url)`: `logBanner` is synchronous, so a
|
||||
// rejection would escape it. See openBrowser.
|
||||
openBrowser(url);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Config object for the web server (dev and prod). Passed in-process; no env handoff.
|
||||
*/
|
||||
|
||||
import open from "open";
|
||||
import pino from "pino";
|
||||
import type { Logger } from "pino";
|
||||
import type { MCPConfig, MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
|
|
@ -203,6 +204,24 @@ export function printServerBanner(
|
|||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the user's browser at `url`, reporting a failure rather than throwing.
|
||||
*
|
||||
* Both callers — the prod Hono server's `serve()` listen callback and the dev
|
||||
* plugin's `logBanner` — are synchronous, so a rejection has nowhere to go and
|
||||
* escapes as an unhandled rejection (#1959). On a headless or otherwise
|
||||
* browser-less host that rejection is the ordinary outcome, not an edge case,
|
||||
* and by the time it happens the server is already listening and
|
||||
* `printServerBanner` has printed a perfectly usable URL. So warn and carry
|
||||
* on. Shared between the two servers deliberately: two copies of a one-line
|
||||
* `.catch` is two chances for one of them to drift back to a bare `open(url)`.
|
||||
*/
|
||||
export function openBrowser(url: string): void {
|
||||
open(url).catch((err: unknown) => {
|
||||
console.warn("Could not open a browser automatically:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildWebServerConfigOptions {
|
||||
initialMcpConfig?: MCPServerConfig | null;
|
||||
/** Catalog/session file the backend should serve; see {@link WebServerConfig.mcpConfigPath}. */
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSu
|
|||
import {
|
||||
cleanRoots,
|
||||
oauthAuthorizationParamsFromSettings,
|
||||
oauthEndpointOverridesFromSettings,
|
||||
serializeMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
|
|
@ -2315,12 +2316,16 @@ function App() {
|
|||
const serverAuthorizationParams = savedSettings
|
||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||
: undefined;
|
||||
const serverEndpointOverrides = savedSettings
|
||||
? oauthEndpointOverridesFromSettings(savedSettings)
|
||||
: undefined;
|
||||
const oauthFromServer =
|
||||
savedSettings &&
|
||||
(savedSettings.oauthClientId ||
|
||||
savedSettings.oauthClientSecret ||
|
||||
savedSettings.oauthScopes ||
|
||||
serverAuthorizationParams ||
|
||||
serverEndpointOverrides ||
|
||||
savedSettings.enterpriseManaged)
|
||||
? {
|
||||
...(savedSettings.oauthClientId && {
|
||||
|
|
@ -2335,6 +2340,7 @@ function App() {
|
|||
...(serverAuthorizationParams && {
|
||||
authorizationParams: serverAuthorizationParams,
|
||||
}),
|
||||
...serverEndpointOverrides,
|
||||
...(savedSettings.enterpriseManaged && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ 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();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ 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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ export const Collapsed: Story = {
|
|||
args: { expanded: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("button", { name: "Expand" })).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Expand" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -23,7 +25,7 @@ export const Expanded: Story = {
|
|||
args: { expanded: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Collapse" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const HeaderMismatch: Story = {
|
|||
args: { code: -32020, name: "HeaderMismatch" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ export const MissingCapability: Story = {
|
|||
args: { code: -32021, name: "MissingRequiredClientCapability" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByText("-32021 MissingRequiredClientCapability"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
@ -32,7 +32,7 @@ export const UnsupportedVersion: Story = {
|
|||
args: { code: -32022, name: "UnsupportedProtocolVersion" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByText("-32022 UnsupportedProtocolVersion"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
@ -47,6 +47,6 @@ export const MethodNotFound: Story = {
|
|||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue