Merge remote-tracking branch 'origin/v2/fix/1909-salvage-malformed-list-items' into v2/fix/1909-salvage-malformed-list-items
This commit is contained in:
commit
23fe62ec5c
3 changed files with 154 additions and 4 deletions
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
import {
|
||||||
|
MODERN_PROTOCOL_VERSION,
|
||||||
|
eraToVersionNegotiation,
|
||||||
|
} from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for #1990: the connect-time `initialLoggingLevel` must be
|
||||||
|
* gated on the negotiated era, not on the server capability alone.
|
||||||
|
*
|
||||||
|
* `logging/setLevel` is a legacy-era method, and the modern wire era rejects it
|
||||||
|
* outright ("Method 'logging/setLevel' is not supported by the negotiated
|
||||||
|
* protocol version"). Because the call sat in `connect()`'s post-handshake
|
||||||
|
* sequence, that rejection failed the *connection* — so the CLI (the one caller
|
||||||
|
* that passes `initialLoggingLevel`, hardcoded to "debug" in
|
||||||
|
* `clients/cli/src/cli.ts`) could not run *any* method against a modern server
|
||||||
|
* that advertised `logging`, logging-related or not.
|
||||||
|
*
|
||||||
|
* The two cases below are the era split: legacy still sends the request with the
|
||||||
|
* configured level, modern connects cleanly and never puts the method on the
|
||||||
|
* wire. The modern half asserts on the sent methods rather than on connect
|
||||||
|
* succeeding alone — a server that happens to answer the method would hide the
|
||||||
|
* regression behind a green connect.
|
||||||
|
*
|
||||||
|
* A fake transport carries both. The modern leg needs no `initialize` at all:
|
||||||
|
* the SDK negotiates with a `server/discover` probe and adopts that result's
|
||||||
|
* capabilities directly, which is also what lets one small class serve both eras
|
||||||
|
* by answering whichever handshake arrives.
|
||||||
|
*/
|
||||||
|
class CapabilityAdvertisingTransport implements Transport {
|
||||||
|
onmessage?: (message: JSONRPCMessage) => void;
|
||||||
|
onclose?: () => void;
|
||||||
|
onerror?: (error: Error) => void;
|
||||||
|
|
||||||
|
/** Every method this client put on the wire, in order. */
|
||||||
|
readonly sentMethods: string[] = [];
|
||||||
|
|
||||||
|
/** The params of the `logging/setLevel` request, if one was sent. */
|
||||||
|
loggingSetLevelParams?: Record<string, unknown>;
|
||||||
|
|
||||||
|
async start(): Promise<void> {}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage): Promise<void> {
|
||||||
|
if (!("method" in message)) return;
|
||||||
|
this.sentMethods.push(message.method);
|
||||||
|
|
||||||
|
// The modern era's handshake: one probe, answered with the capabilities the
|
||||||
|
// client then treats as the server's.
|
||||||
|
if (message.method === "server/discover" && "id" in message) {
|
||||||
|
this.reply(message.id, {
|
||||||
|
supportedVersions: [MODERN_PROTOCOL_VERSION],
|
||||||
|
capabilities: { logging: {} },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The legacy era's handshake.
|
||||||
|
if (message.method === "initialize" && "id" in message) {
|
||||||
|
const params = message.params as { protocolVersion: string };
|
||||||
|
this.reply(message.id, {
|
||||||
|
protocolVersion: params.protocolVersion,
|
||||||
|
capabilities: { logging: {} },
|
||||||
|
serverInfo: { name: "logging-server", version: "1.0.0" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.method === "logging/setLevel" && "id" in message) {
|
||||||
|
this.loggingSetLevelParams = message.params as Record<string, unknown>;
|
||||||
|
this.reply(message.id, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reply(id: string | number, result: Record<string, unknown>): void {
|
||||||
|
// The SDK resolves the request from its own microtask; delivering
|
||||||
|
// synchronously from inside `send()` is the earliest any server could.
|
||||||
|
this.onmessage?.({ jsonrpc: "2.0", id, result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWith(
|
||||||
|
era: "legacy" | "modern",
|
||||||
|
): Promise<CapabilityAdvertisingTransport> {
|
||||||
|
const transport = new CapabilityAdvertisingTransport();
|
||||||
|
const client = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: "https://mcp.example/mcp" },
|
||||||
|
{
|
||||||
|
environment: { transport: () => ({ transport }) },
|
||||||
|
versionNegotiation: eraToVersionNegotiation(era),
|
||||||
|
initialLoggingLevel: "debug",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getProtocolEra()).toBe(era);
|
||||||
|
await client.disconnect();
|
||||||
|
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("InspectorClient connect() initial logging level, by era (#1990)", () => {
|
||||||
|
it("sends logging/setLevel on a legacy connection", async () => {
|
||||||
|
const transport = await connectWith("legacy");
|
||||||
|
|
||||||
|
expect(transport.sentMethods).toContain("logging/setLevel");
|
||||||
|
expect(transport.loggingSetLevelParams).toMatchObject({ level: "debug" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends logging/setLevel on a modern connection", async () => {
|
||||||
|
const transport = await connectWith("modern");
|
||||||
|
|
||||||
|
// Modern has no session-scoped level: the equivalent is the per-request
|
||||||
|
// `io.modelcontextprotocol/logLevel` `_meta` opt-in, driven by the
|
||||||
|
// `modernLogLevel` server setting rather than by `initialLoggingLevel`.
|
||||||
|
expect(transport.sentMethods).not.toContain("logging/setLevel");
|
||||||
|
// The server did advertise logging, so the capability check alone would
|
||||||
|
// have let the call through — the era gate is what stops it.
|
||||||
|
expect(transport.sentMethods).toContain("server/discover");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1977,8 +1977,23 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
// capabilities and wipe tools/prompts/resources to empty on every connect.
|
// capabilities and wipe tools/prompts/resources to empty on every connect.
|
||||||
await this.fetchServerInfo();
|
await this.fetchServerInfo();
|
||||||
|
|
||||||
// Set initial logging level if configured and server supports it
|
// Set initial logging level if configured and server supports it.
|
||||||
if (this.initialLoggingLevel && this.capabilities?.logging) {
|
//
|
||||||
|
// Era-gated (#1990): `logging/setLevel` is a legacy-era method, and the
|
||||||
|
// modern wire era rejects it outright — so an unconditional call here
|
||||||
|
// failed the *connect* of every modern server advertising `logging`,
|
||||||
|
// taking down commands that have nothing to do with logging. Modern has
|
||||||
|
// no session-scoped level at all: the equivalent is the per-request
|
||||||
|
// `io.modelcontextprotocol/logLevel` `_meta` opt-in, which
|
||||||
|
// `modernLogLevel` already drives from the server settings (see
|
||||||
|
// {@link setModernLogLevel}). So the right behavior on modern is to skip
|
||||||
|
// this call rather than translate it — `initialLoggingLevel` names a
|
||||||
|
// mechanism that era does not have.
|
||||||
|
if (
|
||||||
|
this.initialLoggingLevel &&
|
||||||
|
this.capabilities?.logging &&
|
||||||
|
this.protocolEra !== "modern"
|
||||||
|
) {
|
||||||
await this.client.setLoggingLevel(
|
await this.client.setLoggingLevel(
|
||||||
this.initialLoggingLevel,
|
this.initialLoggingLevel,
|
||||||
this.getRequestOptions(),
|
this.getRequestOptions(),
|
||||||
|
|
|
||||||
|
|
@ -919,8 +919,16 @@ export interface InspectorClientOptions {
|
||||||
pipeStderr?: boolean;
|
pipeStderr?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initial logging level to set after connection (if server supports logging)
|
* Initial logging level to set after connection, via `logging/setLevel`.
|
||||||
* If not provided, logging level will not be set automatically
|
* If not provided, the logging level will not be set automatically.
|
||||||
|
*
|
||||||
|
* **Legacy era only (#1990).** `logging/setLevel` is a legacy-era method, and
|
||||||
|
* the modern (2026-07-28) era rejects it — so this option is ignored on a
|
||||||
|
* modern connection even when the server advertises `logging`. Modern has no
|
||||||
|
* session-scoped level at all: the equivalent is the per-request
|
||||||
|
* `io.modelcontextprotocol/logLevel` `_meta` opt-in, configured through the
|
||||||
|
* `modernLogLevel` server setting and applied via
|
||||||
|
* `InspectorClient.setModernLogLevel()`.
|
||||||
*/
|
*/
|
||||||
initialLoggingLevel?: LoggingLevel;
|
initialLoggingLevel?: LoggingLevel;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue