Merge branch 'v2/main' into v2/docs/1822-migration-guide

Took v2/main's project-layout line and quality-gate table (adding
verify:dep-lockstep), keeping this branch's migration-guide mention and
prettier table alignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
This commit is contained in:
cliffhall 2026-08-11 11:18:35 -04:00
commit 0a2b7f7179
12 changed files with 2196 additions and 2291 deletions

File diff suppressed because it is too large Load diff

View file

@ -32,10 +32,6 @@
"format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
},
"dependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -43,7 +39,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"undici": "^8.5.0",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View file

@ -8,8 +8,6 @@
"name": "@modelcontextprotocol/inspector-tui",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -20,7 +18,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"bin": {
"mcp-inspector-tui": "build/index.js"
@ -1054,36 +1052,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz",
"integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"jose": "^6.1.3",
"pkce-challenge": "^5.0.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz",
"integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==",
"license": "MIT",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@napi-rs/keyring": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
@ -2830,6 +2798,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@ -3247,27 +3216,6 @@
"node": ">=0.10.0"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@ -3793,6 +3741,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@ -3834,15 +3783,6 @@
"node": ">=8"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@ -4529,6 +4469,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4608,15 +4549,6 @@
"node": ">= 6"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
@ -4964,6 +4896,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@ -4976,6 +4909,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -6211,6 +6145,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"

View file

@ -27,8 +27,6 @@
"format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
},
"dependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -39,7 +37,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"overrides": {
"ink-select-input": "^6.2.0"

File diff suppressed because it is too large Load diff

View file

@ -41,11 +41,6 @@
"@mantine/form": "^8.3.17",
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -61,7 +56,7 @@
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"zod": "~4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.2.1",
@ -87,7 +82,6 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-storybook": "^10.5.5",
"express": "^5.2.1",
"globals": "^17.4.0",
"happy-dom": "^20.9.0",
"playwright": "^1.58.2",

View file

@ -204,6 +204,129 @@ describe("ToolControls", () => {
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
// A server may legitimately return the same tool name twice; keying rows by
// name alone collides, and React then reuses the stale row instead of
// unmounting it when the filter narrows (#1957).
const duplicateNameTools: Tool[] = [
{
name: "get_record",
title: "Get Record First",
inputSchema: { type: "object" },
},
{
name: "duplicate_tool",
title: "First Duplicate",
inputSchema: { type: "object" },
},
{
name: "unrelated_tool",
title: "Unrelated Tool First",
inputSchema: { type: "object" },
},
{
name: "duplicate_tool",
title: "Second Duplicate",
inputSchema: { type: "object" },
},
{
name: "get_record",
title: "Get Record Second",
inputSchema: { type: "object" },
},
{
name: "unrelated_tool",
title: "Unrelated Tool Second",
inputSchema: { type: "object" },
},
];
it("removes every non-matching row when tool names repeat (#1957)", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledToolControls tools={duplicateNameTools} />);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("Get Record First")).toBeInTheDocument();
expect(screen.getByText("Get Record Second")).toBeInTheDocument();
for (const stale of [
"First Duplicate",
"Second Duplicate",
"Unrelated Tool First",
"Unrelated Tool Second",
]) {
expect(screen.queryByText(stale)).not.toBeInTheDocument();
}
});
// The shape `test-servers/configs/duplicate-tool-names-http.json` serves: the
// repeats are appended after the whole list rather than sitting beside their
// twin. Distinct from the fixture above and worth its own case — React
// matches a leading run of same-key children first, so where the duplicates
// sit decides which row gets orphaned (#1957).
const appendedDuplicateTools: Tool[] = [
{ name: "get_weather", inputSchema: { type: "object" } },
{ name: "get_temp", inputSchema: { type: "object" } },
{ name: "echo", inputSchema: { type: "object" } },
{ name: "add", inputSchema: { type: "object" } },
{
name: "get_weather",
title: "get_weather (duplicate)",
inputSchema: { type: "object" },
},
{
name: "echo",
title: "echo (duplicate)",
inputSchema: { type: "object" },
},
];
it("removes every non-matching row when repeats are appended (#1957)", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledToolControls tools={appendedDuplicateTools} />,
);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("get_temp")).toBeInTheDocument();
expect(screen.getByText("get_weather (duplicate)")).toBeInTheDocument();
expect(screen.queryByText("add")).not.toBeInTheDocument();
// The row the collision orphaned on the broken build.
expect(screen.queryByText("echo")).not.toBeInTheDocument();
expect(screen.queryByText("echo (duplicate)")).not.toBeInTheDocument();
});
it("removes every non-matching excluded row when names repeat (#1957)", async () => {
const user = userEvent.setup();
const duplicateExcluded = [
{
tool: { name: "get_thing", inputSchema: { type: "object" as const } },
reason: "a",
},
{
tool: { name: "dupe", inputSchema: { type: "object" as const } },
reason: "b",
},
{
tool: { name: "dupe", inputSchema: { type: "object" as const } },
reason: "c",
},
{
tool: { name: "get_other", inputSchema: { type: "object" as const } },
reason: "d",
},
];
renderWithMantine(
<ControlledToolControls tools={[]} excludedTools={duplicateExcluded} />,
);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("get_thing")).toBeInTheDocument();
expect(screen.getByText("get_other")).toBeInTheDocument();
expect(screen.queryByText("dupe")).not.toBeInTheDocument();
});
it("renders no load error by default", () => {
renderWithMantine(<ToolControls {...baseProps} />);
expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument();

View file

@ -108,6 +108,18 @@ const ExcludedTooltip = Tooltip.withProps({
position: "right",
});
// A server may return the same tool name more than once, so the name alone is
// not a unique React key — colliding keys let a filtered-out row survive
// reconciliation instead of unmounting (#1957). The tool's position in the
// unfiltered list disambiguates duplicates and stays stable while the search
// narrows, since it is captured before filtering.
const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`;
/** Matches a tool against the (already lower-cased) search query by name or title. */
const matchesQuery = (tool: Tool, query: string) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false);
export function ToolControls({
tools,
excludedTools = [],
@ -122,22 +134,19 @@ export function ToolControls({
}: ToolControlsProps) {
const viewportRef = useScrollMemory("tools-sidebar");
const query = searchText.toLowerCase();
const filteredTools = searchText
? tools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false),
)
: tools;
// Stamp each row's source position before filtering, so the key survives the
// list narrowing (#1957).
const filteredTools = tools
.map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) }))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
// Excluded tools are searchable too, matching name AND title like the main
// list above, so a filtered view stays consistent.
const filteredExcluded = searchText
? excludedTools.filter(
({ tool }) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false),
)
: excludedTools;
const filteredExcluded = excludedTools
.map((excluded, sourceIndex) => ({
...excluded,
key: rowKey(excluded.tool.name, sourceIndex),
}))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
return (
<SidebarStack>
@ -156,9 +165,9 @@ export function ToolControls({
<ListLoadError error={loadError} what="tools" onRetry={onRefreshList} />
<SidebarScroll viewportRef={viewportRef}>
<Stack gap="xs">
{filteredTools.map((tool) => (
{filteredTools.map(({ tool, key }) => (
<ToolListItem
key={tool.name}
key={key}
tool={tool}
selected={tool.name === selectedName}
onClick={() => {
@ -169,8 +178,8 @@ export function ToolControls({
{filteredExcluded.length > 0 && (
<>
<ExcludedDivider />
{filteredExcluded.map(({ tool, reason }) => (
<ExcludedTooltip key={tool.name} label={reason}>
{filteredExcluded.map(({ tool, reason, key }) => (
<ExcludedTooltip key={key} label={reason}>
<ExcludedRow>
<ExcludedWarningIcon>
<RiErrorWarningLine />

View file

@ -515,4 +515,58 @@ describe("RemoteClientTransport", () => {
reason: "token_expired",
});
});
// #1935: the SDK Client calls setProtocolVersion() on this transport, but the
// real HTTP transport lives on the backend — so the version has to ride the
// send envelope or the backend drops `Mcp-Protocol-Version` upstream.
it("forwards the negotiated protocol version on every send after setProtocolVersion", async () => {
const seenBodies: string[] = [];
let sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect")) {
return new Response(JSON.stringify({ sessionId: "abc" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
sse = createPushableSseStream();
return sse.response;
}
if (url.endsWith("/api/mcp/send")) {
seenBodies.push(String(init?.body));
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
await transport.start();
// Pre-initialize sends carry no version (none negotiated yet).
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
expect(JSON.parse(seenBodies[0]!)).not.toHaveProperty("protocolVersion");
transport.setProtocolVersion("2025-11-25");
// The first post-initialize send is `notifications/initialized` — the one
// the reporting server rejected for a missing header.
await transport.send({
jsonrpc: "2.0",
method: "notifications/initialized",
});
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
expect(JSON.parse(seenBodies[1]!)).toMatchObject({
protocolVersion: "2025-11-25",
message: { method: "notifications/initialized" },
});
expect(JSON.parse(seenBodies[2]!)).toMatchObject({
protocolVersion: "2025-11-25",
});
await transport.close();
});
});

View file

@ -0,0 +1,171 @@
import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createEchoTool,
createGetWeatherTool,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of `ServerConfig.duplicateToolNames` (#1957) — the only way this
* repo can serve a `tools/list` that repeats a name, since every preset
* registers a unique one and the SDK's `registerTool` rejects a repeat.
*
* The Tools sidebar keyed its rows by `tool.name`, so duplicates collided and
* filtering left an unrelated row mounted. The component-level regressions live
* in `ToolControls.test.tsx`; this file covers the server option those
* screenshots and the manual repro depend on — the wire shape, the ordering
* that makes the defect observable, and the config plumbing.
*/
describe("duplicate tool names in tools/list (#1957)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
async function start(config: Partial<ServerConfig>): Promise<TestServerHttp> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("duplicate-tool-names-test", "1.0.0"),
tools: [createEchoTool(), createGetWeatherTool()],
...config,
});
await started.start();
server = started;
return started;
}
async function connect(url: string): Promise<InspectorClient> {
const connected = new InspectorClient(
{ type: "streamable-http", url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
it("emits the named tools twice, repeats appended, second copy titled", async () => {
const started = await start({ duplicateToolNames: ["echo"] });
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
// Repeats go at the END, not beside their twin. That ordering is
// load-bearing: React matches a leading run of same-key children first, so
// an adjacent duplicate lines up and the defect hides. Asserting the exact
// sequence keeps a future "tidy-up" from silently defanging the fixture.
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather", "echo"]);
// These presets carry no title, so the marker falls back to the name —
// which is what keeps the two rows distinguishable on screen.
expect(tools.at(-1)?.title).toBe("echo (duplicate)");
// Only the appended copy is marked; the originals are passed through as-is.
expect(tools[0]?.title).toBeUndefined();
expect(tools[1]?.title).toBeUndefined();
});
it("leaves the list alone when no names are given", async () => {
const started = await start({ duplicateToolNames: [] });
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
});
it("ignores a name that is not registered", async () => {
const started = await start({ duplicateToolNames: ["not_a_tool"] });
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
});
it("duplicates before paginating, so a pair straddles a page boundary", async () => {
const started = await start({
duplicateToolNames: ["echo", "get_weather"],
maxPageSize: { tools: 2 },
});
const connected = await connect(started.url);
// Four tools at a page size of two: the duplicated copies land on page 2,
// which only holds if duplication runs before the slice.
const firstPage = await connected.listTools();
expect(firstPage.tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
expect(firstPage.nextCursor).toBeDefined();
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual([
"echo",
"get_weather",
"echo",
"get_weather",
]);
expect(tools.slice(2).map((t) => t.title)).toEqual([
"echo (duplicate)",
"get_weather (duplicate)",
]);
});
it("serves the shape the showcase config declares", async () => {
// Covers the JSON → ConfigFile → ServerConfig plumbing, not just the
// in-process option: a config file is how the manual repro and the
// screenshots in #1957 are produced.
const configPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs/duplicate-tool-names-http.json",
);
const resolved = resolveConfig(loadConfig(configPath));
expect(resolved.duplicateToolNames).toEqual(["get_weather", "echo"]);
// Let the harness pick the port instead of the config's fixed one, so this
// test can't collide with a manually-running showcase server.
const started = await start({
tools: resolved.tools,
duplicateToolNames: resolved.duplicateToolNames,
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual([
"get_weather",
"get_temp",
"echo",
"add",
"get_weather",
"echo",
]);
// The whole point of the fixture: filtering by "get" must be able to drop
// every non-matching row, duplicates included.
const matching = tools.filter(
(t) =>
t.name.includes("get") ||
(t.title?.toLowerCase().includes("get") ?? false),
);
expect(matching).toHaveLength(3);
});
});

View file

@ -25,6 +25,16 @@ function makeFetchEntry(
};
}
/** A structurally valid `Transport`, so a fixture needs no cast. */
function makeTransport(overrides: Partial<Transport> = {}): Transport {
return {
start: async () => {},
send: async () => {},
close: async () => {},
...overrides,
};
}
describe("RemoteSession", () => {
it("queues events before a consumer attaches and drains them on attach", () => {
const session = new RemoteSession("s1");
@ -120,6 +130,45 @@ describe("RemoteSession", () => {
expect(session.transport).toBe(transport);
});
// #1935: the browser's Client negotiates the version, so the backend has to
// be told before it can stamp `Mcp-Protocol-Version` on upstream requests.
it("applyProtocolVersion forwards a new version to the transport once", () => {
const session = new RemoteSession("s8a");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion("2025-11-25");
session.applyProtocolVersion("2025-11-25");
expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]);
// A renegotiated version (e.g. after a reconnect) is re-applied.
session.applyProtocolVersion("2026-07-28");
expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28");
});
it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => {
const session = new RemoteSession("s8b");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion(undefined);
// Header injection attempt — must never reach the upstream transport.
session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1");
session.applyProtocolVersion("");
// The value arrives from an unvalidated JSON body, so a non-string must be
// rejected on its type — `RegExp.test` would coerce these into a match.
session.applyProtocolVersion(123);
session.applyProtocolVersion(true);
session.applyProtocolVersion(null);
expect(setProtocolVersion).not.toHaveBeenCalled();
});
it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => {
const session = new RemoteSession("s8c");
session.setTransport(makeTransport());
expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow();
});
it("hasEventConsumer reflects whether a consumer is attached", () => {
const session = new RemoteSession("s9");
expect(session.hasEventConsumer()).toBe(false);

View file

@ -452,6 +452,58 @@ describe("Remote transport e2e", () => {
}
});
it("end-to-end: the negotiated Mcp-Protocol-Version reaches the upstream server after initialize (#1935)", async () => {
// The browser's SDK Client owns the initialize handshake, so its
// setProtocolVersion() lands on the *remote* transport. Without the
// forward to the backend, the backend's real streamable-HTTP transport
// never learns the version and every post-initialize request — starting
// with `notifications/initialized` — goes out without the header, which
// a stateful server may reject outright.
mcpHttpServer = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
serverType: "streamable-http",
});
await mcpHttpServer.start();
const config: MCPServerConfig = {
type: "streamable-http",
url: mcpHttpServer.url,
};
const { client, fetchRequestLogState } =
await setupRemoteAndConnect(config);
try {
await client.listTools();
const protocolVersion = client.getProtocolVersion();
expect(protocolVersion).toBeDefined();
const posts = fetchRequestLogState
.getFetchRequests()
.filter((r) => r.method === "POST");
const headerFor = (body: string): string | undefined => {
const entry = posts.find((r) => (r.requestBody ?? "").includes(body));
expect(entry).toBeDefined();
for (const [k, v] of Object.entries(entry?.requestHeaders ?? {})) {
if (k.toLowerCase() === "mcp-protocol-version") return v;
}
return undefined;
};
// The initialize POST itself predates negotiation and carries none.
expect(headerFor('"method":"initialize"')).toBeUndefined();
// Everything after it does.
expect(headerFor('"method":"notifications/initialized"')).toBe(
protocolVersion,
);
expect(headerFor('"method":"tools/list"')).toBe(protocolVersion);
} finally {
await client.disconnect();
}
});
it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => {
// A modern (2026-07-28) server whose `get_weather` tool annotates `city`
// with `x-mcp-header: "City"`. The SDK's modern handler validates the