Merge branch 'v2/main' into v2/feat/1915-manual-server-headers
This commit is contained in:
commit
4d7405bea0
53 changed files with 2747 additions and 136 deletions
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const Outgoing: Story = {
|
|||
args: { direction: "outgoing" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("client → server")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("client → server")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ export const Incoming: Story = {
|
|||
args: { direction: "incoming" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("server → client")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("server → client")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const ToolsList: Story = {
|
|||
args: { method: "tools/list" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("tools/list")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("tools/list")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ export const TasksList: Story = {
|
|||
args: { method: "tasks/list" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("tasks/list")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("tasks/list")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const Closed: Story = {
|
|||
args: { open: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Open monitoring sidebar" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
@ -25,7 +25,7 @@ export const Open: Story = {
|
|||
args: { open: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Close monitoring sidebar" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ export const Unpinned: Story = {
|
|||
args: { pinned: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("button", { name: "Pin" })).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Pin" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -23,6 +25,8 @@ export const Pinned: Story = {
|
|||
args: { pinned: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("button", { name: "Unpin" })).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Unpin" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type Story = StoryObj<typeof ReplayButton>;
|
|||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Replay" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const Default: Story = {
|
|||
render: () => <ResizeDemo />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByRole("separator", { name: "Resize demo panel" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const FillsHostWidth: Story = {
|
|||
// sharper discriminator: a row flex leaves the layer full-width while
|
||||
// collapsing the child to the single "x").
|
||||
const layer = child.parentElement as HTMLElement;
|
||||
expect(child.getBoundingClientRect().width).toBeCloseTo(
|
||||
await expect(child.getBoundingClientRect().width).toBeCloseTo(
|
||||
layer.getBoundingClientRect().width,
|
||||
0,
|
||||
);
|
||||
|
|
@ -77,11 +77,11 @@ export const FillVariant: Story = {
|
|||
// Width still stretches onto the child (relative to the layer, as above);
|
||||
// the `fill` height comes from `bottom: 0` making the layer span the host.
|
||||
const layer = child.parentElement as HTMLElement;
|
||||
expect(child.getBoundingClientRect().width).toBeCloseTo(
|
||||
await expect(child.getBoundingClientRect().width).toBeCloseTo(
|
||||
layer.getBoundingClientRect().width,
|
||||
0,
|
||||
);
|
||||
expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4);
|
||||
await expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -97,6 +97,6 @@ export const Inactive: Story = {
|
|||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@ export const ModernEra: Story = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// SEP-2663 moved task support to the extension map, so the Tasks capability
|
||||
// row must read it there on a modern connection — otherwise a tasks-capable
|
||||
// server shows ✗ while listing the extension below (#1887).
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const tasksRow = canvas.getByText("Tasks");
|
||||
await expect(tasksRow.previousElementSibling).toHaveTextContent("✓");
|
||||
},
|
||||
};
|
||||
|
||||
// A modern server that omitted the optional `_meta` serverInfo stamp (#1772).
|
||||
|
|
@ -106,8 +114,10 @@ export const ServerInfoNotReported: Story = {
|
|||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument();
|
||||
expect(canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength(2);
|
||||
await expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL),
|
||||
).toHaveLength(2);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -187,11 +197,11 @@ export const WithOAuthIdToken: Story = {
|
|||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
||||
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||
);
|
||||
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ const fullClientCaps: ClientCapabilities = {
|
|||
experimental: {},
|
||||
};
|
||||
|
||||
// The glyphs `CapabilityItem` renders beside each capability label.
|
||||
const SUPPORTED_MARK = "✓";
|
||||
const UNSUPPORTED_MARK = "✗";
|
||||
|
||||
/**
|
||||
* Read the ✓/✗ a capability row is showing. The mark is the label's preceding
|
||||
* sibling inside the row `Group`, so asserting on it — rather than on the
|
||||
* label's mere presence — is what distinguishes "supported" from "listed".
|
||||
*/
|
||||
function capabilityMark(label: string): string | undefined {
|
||||
return (
|
||||
screen.getByText(label).previousElementSibling?.textContent ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConnectionInfoContent", () => {
|
||||
it("renders server implementation fields under the heading", () => {
|
||||
renderWithMantine(
|
||||
|
|
@ -181,6 +196,96 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getByText("Sampling")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks Tasks supported on a modern connection that advertises the tasks extension (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
// SEP-2663: no top-level `tasks` key — support is the extension.
|
||||
extensions: { "io.modelcontextprotocol/tasks": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="modern"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("still marks Tasks unsupported on a modern connection without the extension (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="modern"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("does not read the tasks extension on a legacy connection (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
extensions: { "io.modelcontextprotocol/tasks": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
// Legacy tasks support is `capabilities.tasks`, which this server omits —
|
||||
// an extension key alone must not turn the row green.
|
||||
expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("marks Tasks supported on a legacy connection from capabilities.tasks", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
// `ServerTasksCapability`'s sub-capabilities are objects, not
|
||||
// booleans — `{}` is the "supported, no sub-options" shape.
|
||||
tasks: { list: {}, cancel: {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("does not extension-promote a capability other than tasks (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
tools: { listChanged: true },
|
||||
extensions: { "io.modelcontextprotocol/ui": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="modern"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Prompts")).toBe(UNSUPPORTED_MARK);
|
||||
expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("renders instructions when present", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import type {
|
|||
DiscoverResult,
|
||||
InitializeResult,
|
||||
ProtocolEra,
|
||||
ServerCapabilities,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { ServerType } from "@inspector/core/mcp/types.js";
|
||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
|
||||
import {
|
||||
CapabilityItem,
|
||||
|
|
@ -189,16 +191,62 @@ const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [
|
|||
export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL =
|
||||
"Clear OAuth state and disconnect";
|
||||
|
||||
/**
|
||||
* Server capabilities the modern (2026-07-28) era expresses as a negotiated
|
||||
* *extension* rather than a top-level `capabilities` key. `tasks` is the only
|
||||
* one today: SEP-2663 moved task support to
|
||||
* `capabilities.extensions["io.modelcontextprotocol/tasks"]`, so a modern
|
||||
* tasks-capable server left the Tasks row showing a red ✗ while the very same
|
||||
* extension id was listed under "Server Extensions" two sections below
|
||||
* (#1887). Keyed the same way `InspectorClient.isTasksExtensionNegotiated()`
|
||||
* gates the Tasks tab, so the checkmark and the tab agree.
|
||||
*/
|
||||
const MODERN_EXTENSION_BACKED_CAPABILITIES: Partial<
|
||||
Record<CapabilityKey, string>
|
||||
> = {
|
||||
tasks: TASKS_EXTENSION_KEY,
|
||||
};
|
||||
|
||||
function isCapabilityPresent(
|
||||
capabilities: Record<string, unknown>,
|
||||
key: CapabilityKey,
|
||||
): boolean {
|
||||
return key in capabilities && capabilities[key] != null;
|
||||
}
|
||||
|
||||
function getCapabilityEntries(
|
||||
capabilities: Record<string, unknown>,
|
||||
knownKeys: CapabilityKey[],
|
||||
): { capability: CapabilityKey; supported: boolean }[] {
|
||||
return knownKeys.map((key) => ({
|
||||
capability: key,
|
||||
supported: key in capabilities && capabilities[key] != null,
|
||||
supported: isCapabilityPresent(capabilities, key),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side capability entries. Same presence rule as the client column,
|
||||
* plus the modern extension fallback above — gated on the negotiated era so a
|
||||
* legacy connection is still judged purely on its `initialize` capabilities.
|
||||
*/
|
||||
function getServerCapabilityEntries(
|
||||
capabilities: ServerCapabilities,
|
||||
era: ProtocolEra | undefined,
|
||||
): { capability: CapabilityKey; supported: boolean }[] {
|
||||
const modern = isModernEra(era);
|
||||
return SERVER_CAPABILITY_KEYS.map((key) => {
|
||||
const extensionKey = MODERN_EXTENSION_BACKED_CAPABILITIES[key];
|
||||
const viaExtension =
|
||||
modern &&
|
||||
extensionKey !== undefined &&
|
||||
capabilities.extensions?.[extensionKey] != null;
|
||||
return {
|
||||
capability: key,
|
||||
supported: isCapabilityPresent(capabilities, key) || viaExtension,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function ConnectionInfoContent({
|
||||
initializeResult,
|
||||
serverInfoReported = true,
|
||||
|
|
@ -229,7 +277,7 @@ export function ConnectionInfoContent({
|
|||
? serverInfo.version?.trim() || "—"
|
||||
: SERVER_INFO_NOT_REPORTED_LABEL;
|
||||
|
||||
const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS);
|
||||
const serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
|
||||
const clientCaps = getCapabilityEntries(
|
||||
clientCapabilities,
|
||||
CLIENT_CAPABILITY_KEYS,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,6 @@ export const WithEntries: Story = {
|
|||
},
|
||||
// List scrollbar reserves a gutter and stays hidden when idle (#1474).
|
||||
play: async ({ canvasElement }) => {
|
||||
expectScrollbarGutterIdleHidden(canvasElement);
|
||||
await expectScrollbarGutterIdleHidden(canvasElement);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue