Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.
Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
@modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
rewrite (schemas -> core, everything else -> client); the codemod misroutes
client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
2026-era goes on the wire.
Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
implemented over raw client.request + explicit deprecated schemas;
experimental.tasks.callToolStream replaced by a local pollTaskToolCall
async generator (with _progressHandlers revival so task-execution progress
still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
validates a sampling/elicit handler result and rejects the legacy { task }
response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
entry in the private _requestHandlers map for the task-augmented branch so
{ task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
for single-page behavior (v2's client.listX() auto-aggregate all pages).
Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
(remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
vi.mock intercepts the SUT's import.
Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.
npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).
Closes #1624
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1037 lines
37 KiB
TypeScript
1037 lines
37 KiB
TypeScript
/**
|
|
* Coverage backfill for InspectorClient: drives a set of small, individually
|
|
* simple methods and error paths that the broader e2e suite does not reach.
|
|
* Uses the real stdio test server (and HTTP test servers where a network
|
|
* transport is required) so these remain behavioral tests, not mocks.
|
|
*/
|
|
|
|
import { describe, it, expect, afterEach } from "vitest";
|
|
import * as z from "zod/v4";
|
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
|
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
|
import { SamplingCreateMessage } from "@inspector/core/mcp/samplingCreateMessage.js";
|
|
import { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage.js";
|
|
import {
|
|
getTestMcpServerCommand,
|
|
createTestServerHttp,
|
|
type TestServerHttp,
|
|
waitForEvent,
|
|
createEchoTool,
|
|
createTestServerInfo,
|
|
getTaskServerConfig,
|
|
createTaskTool,
|
|
createNumberedResources,
|
|
createNumberedPrompts,
|
|
} from "@modelcontextprotocol/inspector-test-server";
|
|
import type { Tool } from "@modelcontextprotocol/client";
|
|
import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
|
|
|
|
const serverCommand = getTestMcpServerCommand();
|
|
|
|
function stdioClient(): InspectorClient {
|
|
return new InspectorClient(
|
|
{
|
|
type: "stdio",
|
|
command: serverCommand.command,
|
|
args: serverCommand.args,
|
|
},
|
|
{ environment: { transport: createTransportNode } },
|
|
);
|
|
}
|
|
|
|
async function getAllTools(client: InspectorClient): Promise<Tool[]> {
|
|
const collected: Tool[] = [];
|
|
let cursor: string | undefined;
|
|
for (let i = 0; i < 100; i++) {
|
|
const r = await client.listTools(cursor);
|
|
collected.push(...r.tools);
|
|
cursor = r.nextCursor;
|
|
if (!cursor) break;
|
|
}
|
|
return collected;
|
|
}
|
|
|
|
async function getTool(client: InspectorClient, name: string): Promise<Tool> {
|
|
const tool = (await getAllTools(client)).find((t) => t.name === name);
|
|
if (!tool) throw new Error(`Tool ${name} not found`);
|
|
return tool;
|
|
}
|
|
|
|
/**
|
|
* Attach a no-op catch to every outstanding receiver-task payload promise so a
|
|
* deliberate reject (failure-path test) doesn't bubble up as an unhandled
|
|
* rejection. The real consumer (the server polling tasks/result) handles the
|
|
* rejection, but it may not have a handler attached at the instant we reject.
|
|
*/
|
|
function suppressReceiverPayloadRejections(client: InspectorClient): void {
|
|
const records = (
|
|
client as unknown as {
|
|
receiverTaskRecords: Map<string, { payloadPromise: Promise<unknown> }>;
|
|
}
|
|
).receiverTaskRecords;
|
|
for (const record of records.values()) {
|
|
record.payloadPromise.catch(() => {});
|
|
}
|
|
}
|
|
|
|
describe("InspectorClient coverage backfill", () => {
|
|
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;
|
|
}
|
|
});
|
|
|
|
describe("simple accessors and ping", () => {
|
|
it("ping() resolves against a live server and getTransportConfig returns the config", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
await expect(client.ping()).resolves.toBeUndefined();
|
|
|
|
const cfg = client.getTransportConfig();
|
|
expect(cfg.type).toBe("stdio");
|
|
});
|
|
|
|
it("ping() throws when the client is not initialized", async () => {
|
|
const c = stdioClient();
|
|
// Force the private client field to null to hit the guard.
|
|
(c as unknown as { client: unknown }).client = null;
|
|
await expect(c.ping()).rejects.toThrow(/Client not initialized/);
|
|
});
|
|
|
|
it("getClientCapabilities reflects the advertised capabilities", async () => {
|
|
// sampling enabled (default), elicit form enabled (default), roots set.
|
|
client = new InspectorClient(
|
|
{
|
|
type: "stdio",
|
|
command: serverCommand.command,
|
|
args: serverCommand.args,
|
|
},
|
|
{ environment: { transport: createTransportNode }, roots: [] },
|
|
);
|
|
const caps = client.getClientCapabilities();
|
|
expect(caps.sampling).toBeDefined();
|
|
expect(caps.elicitation).toBeDefined();
|
|
expect(caps.roots).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("getAppRendererClient proxy", () => {
|
|
it("returns null before connect and a memoized proxy after connect", async () => {
|
|
client = stdioClient();
|
|
// Not connected yet → null.
|
|
expect(client.getAppRendererClient()).toBeNull();
|
|
|
|
await client.connect();
|
|
const proxy = client.getAppRendererClient();
|
|
expect(proxy).not.toBeNull();
|
|
// Second call returns the same memoized proxy.
|
|
expect(client.getAppRendererClient()).toBe(proxy);
|
|
|
|
// Accessing setNotificationHandler returns the wrapped function (covers
|
|
// the prop === "setNotificationHandler" branch), and accessing another
|
|
// prop returns the underlying value (covers the fall-through return).
|
|
const wrapped = (proxy as unknown as { setNotificationHandler: unknown })
|
|
.setNotificationHandler;
|
|
expect(typeof wrapped).toBe("function");
|
|
const other = (proxy as unknown as { request: unknown }).request;
|
|
expect(typeof other).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("getCompletions error rethrow", () => {
|
|
it("wraps a non-MethodNotFound error as 'Failed to get completions'", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// Force the underlying SDK complete() to throw a non-MethodNotFound error
|
|
// so the catch rethrows it wrapped.
|
|
const c = client as unknown as {
|
|
client: { complete: () => Promise<never> };
|
|
};
|
|
const original = c.client.complete;
|
|
c.client.complete = async () => {
|
|
throw new Error("boom-completions");
|
|
};
|
|
try {
|
|
await expect(
|
|
client.getCompletions({ type: "ref/prompt", name: "x" }, "arg", ""),
|
|
).rejects.toThrow(/Failed to get completions: boom-completions/);
|
|
} finally {
|
|
c.client.complete = original;
|
|
}
|
|
});
|
|
|
|
it("returns empty values gracefully when the server reports MethodNotFound", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: { complete: () => Promise<never> };
|
|
};
|
|
const original = c.client.complete;
|
|
c.client.complete = async () => {
|
|
throw new ProtocolError(
|
|
ProtocolErrorCode.MethodNotFound,
|
|
"Method not found",
|
|
);
|
|
};
|
|
try {
|
|
await expect(
|
|
client.getCompletions({ type: "ref/prompt", name: "x" }, "arg", ""),
|
|
).resolves.toEqual({ values: [] });
|
|
} finally {
|
|
c.client.complete = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("setRoots", () => {
|
|
it("enables roots when previously undefined and dispatches rootsChange", async () => {
|
|
// No roots option → this.roots is undefined initially.
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const rootsChange = waitForEvent(client, "rootsChange", {
|
|
timeout: 3000,
|
|
});
|
|
await client.setRoots([{ uri: "file:///tmp", name: "tmp" }]);
|
|
await rootsChange;
|
|
expect(client.getRoots()).toEqual([{ uri: "file:///tmp", name: "tmp" }]);
|
|
});
|
|
|
|
it("logs (does not throw) when the list_changed notification fails", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: { notification: (...a: unknown[]) => Promise<void> };
|
|
};
|
|
const original = c.client.notification;
|
|
c.client.notification = async () => {
|
|
throw new Error("notify-failed");
|
|
};
|
|
try {
|
|
// Should resolve (error is caught + logged), and roots still update.
|
|
await expect(
|
|
client.setRoots([{ uri: "file:///x", name: "x" }]),
|
|
).resolves.toBeUndefined();
|
|
expect(client.getRoots()).toEqual([{ uri: "file:///x", name: "x" }]);
|
|
} finally {
|
|
c.client.notification = original;
|
|
}
|
|
});
|
|
|
|
it("throws when client is not connected", async () => {
|
|
const c = stdioClient();
|
|
(c as unknown as { client: unknown }).client = null;
|
|
await expect(c.setRoots([])).rejects.toThrow(/Client is not connected/);
|
|
});
|
|
});
|
|
|
|
describe("subscribe / unsubscribe error wrapping", () => {
|
|
it("wraps subscribe failures from a server that advertises subscribe but rejects", async () => {
|
|
// Advertise subscriptions so supportsResourceSubscriptions() passes, then
|
|
// make the SDK subscribeResource reject to hit the catch wrapper.
|
|
server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
resources: [],
|
|
subscriptions: true,
|
|
serverType: "sse",
|
|
});
|
|
await server.start();
|
|
client = new InspectorClient(
|
|
{ type: "sse", url: server.url },
|
|
{ environment: { transport: createTransportNode } },
|
|
);
|
|
await client.connect();
|
|
expect(client.supportsResourceSubscriptions()).toBe(true);
|
|
|
|
const c = client as unknown as {
|
|
client: { subscribeResource: () => Promise<never> };
|
|
};
|
|
c.client.subscribeResource = async () => {
|
|
throw new Error("sub-rejected");
|
|
};
|
|
await expect(client.subscribeToResource("test://nope")).rejects.toThrow(
|
|
/Failed to subscribe to resource: sub-rejected/,
|
|
);
|
|
});
|
|
|
|
it("wraps unsubscribe failures", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: { unsubscribeResource: () => Promise<never> };
|
|
};
|
|
c.client.unsubscribeResource = async () => {
|
|
throw new Error("unsub-rejected");
|
|
};
|
|
await expect(
|
|
client.unsubscribeFromResource("test://nope"),
|
|
).rejects.toThrow(/Failed to unsubscribe from resource: unsub-rejected/);
|
|
});
|
|
});
|
|
|
|
describe("receiver-task failure callbacks (server-driven sampling/elicitation)", () => {
|
|
it("marks the receiver task failed when the sampling request is rejected", async () => {
|
|
const config = {
|
|
...getTaskServerConfig(),
|
|
serverType: "sse" as const,
|
|
tools: [
|
|
createTaskTool({
|
|
name: "receiverRejectSampling",
|
|
samplingText: "Reply please",
|
|
receiverTaskTtl: 5000,
|
|
}),
|
|
...(getTaskServerConfig().tools || []),
|
|
],
|
|
};
|
|
server = createTestServerHttp(config);
|
|
await server.start();
|
|
client = new InspectorClient(
|
|
{ type: "sse", url: server.url },
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
sample: true,
|
|
receiverTasks: true,
|
|
receiverTaskTtlMs: 10_000,
|
|
},
|
|
);
|
|
await client.connect();
|
|
|
|
const samplingPromise = waitForEvent<SamplingCreateMessage>(
|
|
client,
|
|
"newPendingSample",
|
|
{ timeout: 5000 },
|
|
);
|
|
const tool = await getTool(client, "receiverRejectSampling");
|
|
const callPromise = client
|
|
.callToolStream(tool, { message: "x" })
|
|
.catch((e: unknown) => e);
|
|
|
|
const sample = await samplingPromise;
|
|
// Pre-attach a catch to the receiver task's payload promise so its
|
|
// rejection (driven below) doesn't surface as an unhandled rejection.
|
|
suppressReceiverPayloadRejections(client);
|
|
// Reject instead of respond — drives the receiver-task error callback,
|
|
// which sets status "failed" and calls upsertReceiverTask.
|
|
await sample.reject(new Error("user rejected sampling"));
|
|
|
|
const outcome = await callPromise;
|
|
// The rejected payload surfaces as a failed/errored tool call.
|
|
expect(outcome).toBeDefined();
|
|
});
|
|
|
|
it("marks the receiver task failed when the elicitation request is rejected", async () => {
|
|
const config = {
|
|
...getTaskServerConfig(),
|
|
serverType: "sse" as const,
|
|
tools: [
|
|
createTaskTool({
|
|
name: "receiverRejectElicit",
|
|
elicitationSchema: z.object({
|
|
input: z.string().describe("User input"),
|
|
}),
|
|
receiverTaskTtl: 5000,
|
|
}),
|
|
...(getTaskServerConfig().tools || []),
|
|
],
|
|
};
|
|
server = createTestServerHttp(config);
|
|
await server.start();
|
|
client = new InspectorClient(
|
|
{ type: "sse", url: server.url },
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
elicit: true,
|
|
receiverTasks: true,
|
|
receiverTaskTtlMs: 10_000,
|
|
},
|
|
);
|
|
await client.connect();
|
|
|
|
const elicitationPromise = waitForEvent<ElicitationCreateMessage>(
|
|
client,
|
|
"newPendingElicitation",
|
|
{ timeout: 5000 },
|
|
);
|
|
const tool = await getTool(client, "receiverRejectElicit");
|
|
const callPromise = client
|
|
.callToolStream(tool, { message: "x" })
|
|
.catch((e: unknown) => e);
|
|
|
|
const elicitation = await elicitationPromise;
|
|
suppressReceiverPayloadRejections(client);
|
|
await elicitation.reject(new Error("user declined elicitation"));
|
|
|
|
const outcome = await callPromise;
|
|
expect(outcome).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("list/read/get with metadata + cursor (effectiveMeta + cursor branches)", () => {
|
|
it("listResources / listResourceTemplates / listPrompts / listTools accept metadata and cursor", async () => {
|
|
// Server with enough entries to produce a nextCursor under pagination so
|
|
// the cursor branch is exercised on a follow-up call.
|
|
const resources = createNumberedResources(2);
|
|
const prompts = createNumberedPrompts(1);
|
|
server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
resources,
|
|
resourceTemplates: [],
|
|
prompts,
|
|
maxPageSize: { resources: 1 },
|
|
serverType: "streamable-http",
|
|
});
|
|
await server.start();
|
|
client = new InspectorClient(
|
|
{ type: "streamable-http", url: server.url },
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
// Non-empty default metadata so mergeMeta returns a non-empty map →
|
|
// the `effectiveMeta ? { _meta } : {}` truthy branch fires.
|
|
serverSettings: {
|
|
headers: [],
|
|
env: [],
|
|
metadata: [{ key: "x-test", value: "1" }],
|
|
connectionTimeout: 0,
|
|
requestTimeout: 0,
|
|
taskTtl: 0,
|
|
maxFetchRequests: 1000,
|
|
roots: [],
|
|
},
|
|
},
|
|
);
|
|
await client.connect();
|
|
|
|
const meta = { trace: "abc" };
|
|
// First page (no cursor) with metadata.
|
|
const firstResources = await client.listResources(undefined, meta);
|
|
expect(firstResources.resources.length).toBeGreaterThan(0);
|
|
// Follow-up page WITH cursor (exercises the cursor-truthy branch).
|
|
if (firstResources.nextCursor) {
|
|
const secondResources = await client.listResources(
|
|
firstResources.nextCursor,
|
|
meta,
|
|
);
|
|
expect(secondResources.resources).toBeDefined();
|
|
}
|
|
|
|
await client.listResourceTemplates(undefined, meta);
|
|
await client.listResourceTemplates("cursor-x", meta).catch(() => {
|
|
// A bogus cursor may error on some servers; we only need the param path.
|
|
});
|
|
await client.listPrompts(undefined, meta);
|
|
await client.listPrompts("cursor-y", meta).catch(() => {});
|
|
const tools = await client.listTools("cursor-z", meta).catch(() => ({
|
|
tools: [],
|
|
}));
|
|
expect(tools).toBeDefined();
|
|
|
|
// readResource + getPrompt with metadata (effectiveMeta truthy + getPrompt
|
|
// stringArgs non-empty branch).
|
|
const read = await client.readResource("test://resource_1", meta);
|
|
expect(read.result).toBeDefined();
|
|
const prompt = await client.getPrompt("prompt_1", { a: "b" }, meta);
|
|
expect(prompt.result).toBeDefined();
|
|
|
|
// getCompletions with context + metadata (context-truthy + _meta-truthy).
|
|
await client
|
|
.getCompletions(
|
|
{ type: "ref/prompt", name: "prompt_1" },
|
|
"a",
|
|
"",
|
|
{ other: "v" },
|
|
meta,
|
|
)
|
|
.catch(() => {
|
|
// server may not support completions; the param-build branches ran.
|
|
});
|
|
});
|
|
|
|
it("getPrompt without args uses an empty arg map and omits params", async () => {
|
|
const prompts = createNumberedPrompts(1);
|
|
server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
prompts,
|
|
serverType: "sse",
|
|
});
|
|
await server.start();
|
|
client = new InspectorClient(
|
|
{ type: "sse", url: server.url },
|
|
{ environment: { transport: createTransportNode } },
|
|
);
|
|
await client.connect();
|
|
// No args → `args ? convertPromptArguments(args) : {}` false branch and
|
|
// `Object.keys(stringArgs).length > 0 ? stringArgs : undefined` false branch.
|
|
const invocation = await client.getPrompt("prompt_1");
|
|
expect(invocation.result).toBeDefined();
|
|
expect(invocation.params).toBeUndefined();
|
|
});
|
|
|
|
it("getCompletions without context omits the context param", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// No context arg → the `context ? { context } : {}` false branch.
|
|
await client
|
|
.getCompletions({ type: "ref/prompt", name: "x" }, "arg", "")
|
|
.catch(() => {
|
|
// server support varies; the param-build branch is what we exercise.
|
|
});
|
|
});
|
|
|
|
it("getCompletions falls back to empty values when completion omits them", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: { complete: () => Promise<unknown> };
|
|
};
|
|
// completion present but without a `values` field → `|| []` branch.
|
|
c.client.complete = async () => ({ completion: {} });
|
|
const res = await client.getCompletions(
|
|
{ type: "ref/prompt", name: "x" },
|
|
"arg",
|
|
"",
|
|
undefined,
|
|
{ progressToken: "tok" },
|
|
);
|
|
expect(res.values).toEqual([]);
|
|
});
|
|
|
|
it("readResourceFromTemplate wraps a URI-expansion failure", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// A malformed template triggers UriTemplate parse/expand to throw.
|
|
await expect(
|
|
client.readResourceFromTemplate("file:///{unclosed", { path: "x" }),
|
|
).rejects.toThrow(/Failed to expand URI template/);
|
|
});
|
|
});
|
|
|
|
describe("uninitialized-client guards", () => {
|
|
it("attemptToolCall path throws when client is null (via callTool guard)", async () => {
|
|
const c = stdioClient();
|
|
(c as unknown as { client: unknown }).client = null;
|
|
await expect(
|
|
c.callTool({ name: "x", inputSchema: { type: "object" } }, {}),
|
|
).rejects.toThrow(/Client is not connected/);
|
|
});
|
|
|
|
it("fetchServerInfo returns early when client is null", async () => {
|
|
const c = stdioClient();
|
|
(c as unknown as { client: unknown }).client = null;
|
|
await expect(
|
|
(
|
|
c as unknown as { fetchServerInfo: () => Promise<void> }
|
|
).fetchServerInfo(),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("setOAuthConfig succeeds when an oauth manager is configured", async () => {
|
|
const c = new InspectorClient(
|
|
{ type: "sse", url: "http://localhost:9/sse" },
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
oauth: { clientId: "initial" },
|
|
},
|
|
);
|
|
// Manager exists → delegates to it without throwing.
|
|
expect(() => c.setOAuthConfig({ clientId: "updated" })).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("failed tool calls with metadata combinations", () => {
|
|
// A tool the server will reject (unknown name) so the call always errors,
|
|
// exercising the error-path metadata-merge branches in callTool
|
|
// (dispatchFailedToolCall) and callToolStream.
|
|
const badTool: Tool = {
|
|
name: "definitely-not-a-real-tool",
|
|
inputSchema: { type: "object" },
|
|
};
|
|
|
|
it("callTool error path covers general-only, tool-specific-only, and both metadata", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// Force the underlying SDK callTool to throw so the dispatchFailedToolCall
|
|
// error path runs for each metadata combination.
|
|
const c = client as unknown as {
|
|
client: { callTool: () => Promise<never> };
|
|
};
|
|
c.client.callTool = async () => {
|
|
throw new Error("forced-call-failure");
|
|
};
|
|
|
|
await expect(
|
|
client.callTool(badTool, {}, { g: "1" }, undefined),
|
|
).rejects.toThrow(/forced-call-failure/);
|
|
await expect(
|
|
client.callTool(badTool, {}, undefined, { t: "1" }),
|
|
).rejects.toThrow(/forced-call-failure/);
|
|
await expect(
|
|
client.callTool(badTool, {}, { g: "1" }, { t: "2" }),
|
|
).rejects.toThrow(/forced-call-failure/);
|
|
await expect(
|
|
client.callTool(badTool, {}, undefined, undefined),
|
|
).rejects.toThrow(/forced-call-failure/);
|
|
});
|
|
|
|
it("callToolStream error path covers metadata combinations", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// Force the streaming API to throw synchronously so the error-path
|
|
// metadata-merge branches run for each combination. `callToolStream`
|
|
// drives tasks via the private `pollTaskToolCall` generator (SDK v2
|
|
// removed `client.experimental.tasks`), so patch that instead.
|
|
const c = client as unknown as {
|
|
pollTaskToolCall: () => never;
|
|
};
|
|
c.pollTaskToolCall = () => {
|
|
throw new Error("forced-stream-failure");
|
|
};
|
|
|
|
await expect(
|
|
client.callToolStream(badTool, {}, { g: "1" }, undefined),
|
|
).rejects.toThrow(/forced-stream-failure/);
|
|
await expect(
|
|
client.callToolStream(badTool, {}, undefined, { t: "1" }),
|
|
).rejects.toThrow(/forced-stream-failure/);
|
|
await expect(
|
|
client.callToolStream(badTool, {}, { g: "1" }, { t: "2" }),
|
|
).rejects.toThrow(/forced-stream-failure/);
|
|
await expect(
|
|
client.callToolStream(badTool, {}, undefined, undefined),
|
|
).rejects.toThrow(/forced-stream-failure/);
|
|
});
|
|
|
|
it("callToolStream success path covers metadata combinations", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const echo = await getTool(client, "echo");
|
|
|
|
// general-only
|
|
const r1 = await client.callToolStream(
|
|
echo,
|
|
{ message: "a" },
|
|
{ g: "1" },
|
|
undefined,
|
|
);
|
|
expect(r1.success).toBe(true);
|
|
// tool-specific-only
|
|
const r2 = await client.callToolStream(
|
|
echo,
|
|
{ message: "b" },
|
|
undefined,
|
|
{ t: "1" },
|
|
);
|
|
expect(r2.success).toBe(true);
|
|
// both
|
|
const r3 = await client.callToolStream(
|
|
echo,
|
|
{ message: "c" },
|
|
{ g: "1" },
|
|
{ t: "2" },
|
|
);
|
|
expect(r3.success).toBe(true);
|
|
});
|
|
|
|
it("callToolStream throws when client is not connected", async () => {
|
|
const c = stdioClient();
|
|
(c as unknown as { client: unknown }).client = null;
|
|
await expect(c.callToolStream(badTool, {})).rejects.toThrow(
|
|
/Client is not connected/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("connect-time timeout and safe-disconnect", () => {
|
|
it("times out the handshake when connectionTimeout is exceeded", async () => {
|
|
// Point at a non-routable address so the TCP connect HANGS (rather than
|
|
// refusing immediately). With a tiny connectionTimeout the Promise.race
|
|
// timeout wins, exercising the timeout branch (catch + disconnect +
|
|
// rethrow) and the connectPromise.catch(()=>{}) late-rejection absorber.
|
|
client = new InspectorClient(
|
|
{ type: "streamable-http", url: "http://10.255.255.1:81/mcp" },
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
serverSettings: {
|
|
headers: [],
|
|
env: [],
|
|
metadata: [],
|
|
connectionTimeout: 1,
|
|
requestTimeout: 0,
|
|
taskTtl: 0,
|
|
maxFetchRequests: 1000,
|
|
roots: [],
|
|
},
|
|
},
|
|
);
|
|
await expect(client.connect()).rejects.toThrow(/timed out/i);
|
|
client = null;
|
|
});
|
|
|
|
it("disconnect(safeDisconnectTimeout) polls the response handlers before closing", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// A positive safeDisconnectTimeout drives the poll loop. With no pending
|
|
// handlers it returns promptly; the loop guard + close still run.
|
|
await expect(client.disconnect(50)).resolves.toBeUndefined();
|
|
expect(client.getStatus()).toBe("disconnected");
|
|
client = null;
|
|
});
|
|
});
|
|
|
|
describe("non-Error rejections and empty-response fallbacks", () => {
|
|
it("getCompletions wraps a non-Error rejection via String(error)", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: { complete: () => Promise<never> };
|
|
};
|
|
c.client.complete = async () => {
|
|
// Throw a non-Error so the `: String(error)` branch runs.
|
|
throw "string-completions-failure";
|
|
};
|
|
await expect(
|
|
client.getCompletions({ type: "ref/prompt", name: "x" }, "a", ""),
|
|
).rejects.toThrow(
|
|
/Failed to get completions: string-completions-failure/,
|
|
);
|
|
});
|
|
|
|
it("subscribe / unsubscribe wrap a non-Error rejection via String(error)", async () => {
|
|
server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
resources: [],
|
|
subscriptions: true,
|
|
serverType: "sse",
|
|
});
|
|
await server.start();
|
|
client = new InspectorClient(
|
|
{ type: "sse", url: server.url },
|
|
{ environment: { transport: createTransportNode } },
|
|
);
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: {
|
|
subscribeResource: () => Promise<never>;
|
|
unsubscribeResource: () => Promise<never>;
|
|
};
|
|
};
|
|
c.client.subscribeResource = async () => {
|
|
throw "string-sub-failure";
|
|
};
|
|
c.client.unsubscribeResource = async () => {
|
|
throw "string-unsub-failure";
|
|
};
|
|
await expect(client.subscribeToResource("test://x")).rejects.toThrow(
|
|
/Failed to subscribe to resource: string-sub-failure/,
|
|
);
|
|
await expect(client.unsubscribeFromResource("test://x")).rejects.toThrow(
|
|
/Failed to unsubscribe from resource: string-unsub-failure/,
|
|
);
|
|
});
|
|
|
|
it("callToolStream error dispatch handles a non-Error rejection", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const echo = await getTool(client, "echo");
|
|
const c = client as unknown as {
|
|
pollTaskToolCall: () => never;
|
|
};
|
|
c.pollTaskToolCall = () => {
|
|
throw "string-stream-failure";
|
|
};
|
|
await expect(
|
|
client.callToolStream(echo, { message: "x" }, { g: "1" }),
|
|
).rejects.toBe("string-stream-failure");
|
|
});
|
|
|
|
it("list methods fall back to empty arrays when the response omits them", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const c = client as unknown as {
|
|
client: { request: () => Promise<unknown> };
|
|
};
|
|
// InspectorClient's list methods issue raw `client.request({method:"…/list"})`
|
|
// (SDK v2's `client.listX()` auto-aggregate pages), so mock `request` to
|
|
// return a body missing the array field → `|| []` fallback branches.
|
|
c.client.request = async () => ({});
|
|
|
|
expect((await client.listResources()).resources).toEqual([]);
|
|
expect((await client.listResourceTemplates()).resourceTemplates).toEqual(
|
|
[],
|
|
);
|
|
expect((await client.listPrompts()).prompts).toEqual([]);
|
|
expect((await client.listTools()).tools).toEqual([]);
|
|
});
|
|
|
|
it("readResourceFromTemplate expand-failure wraps a non-Error", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
// Make the SDK UriTemplate throw a non-Error by passing a template that
|
|
// the UriTemplate constructor rejects. (If it throws an Error, the Error
|
|
// branch runs instead; either way the catch wrapper is covered.)
|
|
await expect(
|
|
client.readResourceFromTemplate("{a,b,c", { x: "1" }),
|
|
).rejects.toThrow(/Failed to expand URI template/);
|
|
});
|
|
});
|
|
|
|
describe("callToolStream task-result fallback and terminal branches", () => {
|
|
function patchStream(
|
|
c: InspectorClient,
|
|
gen: () => AsyncGenerator<unknown>,
|
|
getTaskResult?: () => Promise<unknown>,
|
|
): void {
|
|
// SDK v2 removed `client.experimental.tasks`; `callToolStream` now drives
|
|
// tasks via the private `pollTaskToolCall` generator, and the no-result
|
|
// fallback fetches the payload with a raw `client.request({method:"tasks/result"})`.
|
|
const internal = c as unknown as {
|
|
pollTaskToolCall: () => AsyncGenerator<unknown>;
|
|
client: {
|
|
request: (
|
|
req: { method: string },
|
|
schema: unknown,
|
|
opts: unknown,
|
|
) => Promise<unknown>;
|
|
};
|
|
};
|
|
internal.pollTaskToolCall = gen;
|
|
if (getTaskResult) {
|
|
const origRequest = internal.client.request.bind(internal.client);
|
|
internal.client.request = (req, schema, opts) =>
|
|
req.method === "tasks/result"
|
|
? getTaskResult()
|
|
: origRequest(req, schema, opts);
|
|
}
|
|
}
|
|
|
|
const fakeTask = (taskId: string) => ({
|
|
taskId,
|
|
status: "working" as const,
|
|
ttl: null,
|
|
createdAt: new Date().toISOString(),
|
|
lastUpdatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
it("falls back to getTaskResult when the stream yields no result message", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const echo = await getTool(client, "echo");
|
|
patchStream(
|
|
client,
|
|
async function* () {
|
|
// taskCreated then taskStatus, but NO result → triggers the fallback.
|
|
yield { type: "taskCreated", task: fakeTask("T1") };
|
|
yield { type: "taskStatus", task: fakeTask("T1") };
|
|
},
|
|
async () => ({ content: [{ type: "text", text: "from-fallback" }] }),
|
|
);
|
|
const result = await client.callToolStream(echo, { message: "x" });
|
|
expect(result.success).toBe(true);
|
|
expect(result.result?.content?.[0]).toMatchObject({
|
|
text: "from-fallback",
|
|
});
|
|
});
|
|
|
|
it("throws when the getTaskResult fallback itself fails", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const echo = await getTool(client, "echo");
|
|
patchStream(
|
|
client,
|
|
async function* () {
|
|
yield { type: "taskCreated", task: fakeTask("T2") };
|
|
},
|
|
async () => {
|
|
throw new Error("fallback-failed");
|
|
},
|
|
);
|
|
await expect(
|
|
client.callToolStream(echo, { message: "x" }),
|
|
).rejects.toThrow(/Tool call did not return a result: fallback-failed/);
|
|
});
|
|
|
|
it("surfaces a stream error message and marks the task failed", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const echo = await getTool(client, "echo");
|
|
patchStream(client, async function* () {
|
|
yield { type: "taskCreated", task: fakeTask("T3") };
|
|
// Empty error message → `message.error.message || "Task execution failed"`
|
|
// falsy branch.
|
|
yield { type: "error", error: { message: "" } };
|
|
});
|
|
await expect(
|
|
client.callToolStream(echo, { message: "x" }),
|
|
).rejects.toThrow(/Task execution failed/);
|
|
});
|
|
|
|
it("treats a stream taskStatus before taskCreated as the task id", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const echo = await getTool(client, "echo");
|
|
patchStream(client, async function* () {
|
|
// taskStatus arrives first (no prior taskCreated) → `if (!taskId)` true.
|
|
yield { type: "taskStatus", task: fakeTask("T4") };
|
|
yield {
|
|
type: "result",
|
|
result: { content: [{ type: "text", text: "ok" }] },
|
|
};
|
|
});
|
|
const result = await client.callToolStream(echo, { message: "x" });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("constructor capability branches and createReceiverTask options", () => {
|
|
it("constructs with no advertised capabilities (clientOptions undefined branch)", async () => {
|
|
// sample:false + elicit:false + no roots + no receiverTasks → the
|
|
// capabilities object is empty, so clientOptions is passed as undefined.
|
|
client = new InspectorClient(
|
|
{
|
|
type: "stdio",
|
|
command: serverCommand.command,
|
|
args: serverCommand.args,
|
|
},
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
sample: false,
|
|
elicit: false,
|
|
},
|
|
);
|
|
const caps = client.getClientCapabilities();
|
|
expect(Object.keys(caps)).toHaveLength(0);
|
|
await client.connect();
|
|
expect(client.getStatus()).toBe("connected");
|
|
});
|
|
|
|
it("createReceiverTask honors pollInterval and statusMessage options", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const internal = client as unknown as {
|
|
createReceiverTask: (opts: {
|
|
initialStatus: string;
|
|
ttl?: number;
|
|
pollInterval?: number;
|
|
statusMessage?: string;
|
|
}) => {
|
|
task: {
|
|
taskId: string;
|
|
pollInterval?: number;
|
|
statusMessage?: string;
|
|
};
|
|
};
|
|
};
|
|
const record = internal.createReceiverTask({
|
|
initialStatus: "working",
|
|
ttl: 5000,
|
|
pollInterval: 250,
|
|
statusMessage: "in progress",
|
|
});
|
|
expect(record.task.pollInterval).toBe(250);
|
|
expect(record.task.statusMessage).toBe("in progress");
|
|
|
|
// Omitting ttl falls back to the configured numeric receiverTaskTtlMs
|
|
// (default 60_000) → the non-function branch of the ttl resolution.
|
|
const recordNoTtl = internal.createReceiverTask({
|
|
initialStatus: "working",
|
|
}) as unknown as { task: { ttl: number } };
|
|
expect(recordNoTtl.task.ttl).toBe(60_000);
|
|
});
|
|
|
|
it("createReceiverTask falls back to the configured TTL when ttl is omitted (function form)", async () => {
|
|
// receiverTaskTtlMs as a function → exercises the typeof === 'function'
|
|
// branch of the ttl resolution.
|
|
client = new InspectorClient(
|
|
{
|
|
type: "stdio",
|
|
command: serverCommand.command,
|
|
args: serverCommand.args,
|
|
},
|
|
{
|
|
environment: { transport: createTransportNode },
|
|
receiverTasks: true,
|
|
receiverTaskTtlMs: () => 1234,
|
|
},
|
|
);
|
|
await client.connect();
|
|
const internal = client as unknown as {
|
|
createReceiverTask: (opts: { initialStatus: string }) => {
|
|
task: { ttl: number };
|
|
};
|
|
};
|
|
const record = internal.createReceiverTask({ initialStatus: "working" });
|
|
expect(record.task.ttl).toBe(1234);
|
|
});
|
|
});
|
|
|
|
describe("emitReceiverTaskStatus guards", () => {
|
|
it("is a no-op when there is no connected client", () => {
|
|
const c = stdioClient();
|
|
(c as unknown as { client: unknown }).client = null;
|
|
// Should not throw even though client is null.
|
|
expect(() =>
|
|
(
|
|
c as unknown as { emitReceiverTaskStatus: (t: unknown) => void }
|
|
).emitReceiverTaskStatus({ taskId: "t" }),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("swallows notification-build errors via the catch path", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const internal = client as unknown as {
|
|
emitReceiverTaskStatus: (t: unknown) => void;
|
|
};
|
|
// Passing a malformed task makes TaskStatusNotificationSchema.parse throw,
|
|
// which is caught and logged (no throw).
|
|
expect(() =>
|
|
internal.emitReceiverTaskStatus({ not: "a task" }),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("upsertReceiverTask emits status for an existing record", async () => {
|
|
client = stdioClient();
|
|
await client.connect();
|
|
const internal = client as unknown as {
|
|
createReceiverTask: (opts: { initialStatus: string; ttl?: number }) => {
|
|
task: { taskId: string; status: string };
|
|
};
|
|
upsertReceiverTask: (t: { taskId: string; status: string }) => void;
|
|
getReceiverTask: (
|
|
id: string,
|
|
) => { task: { status: string } } | undefined;
|
|
};
|
|
const record = internal.createReceiverTask({
|
|
initialStatus: "working",
|
|
ttl: 5000,
|
|
});
|
|
const updated = { ...record.task, status: "completed" };
|
|
internal.upsertReceiverTask(updated);
|
|
expect(internal.getReceiverTask(record.task.taskId)?.task.status).toBe(
|
|
"completed",
|
|
);
|
|
// upsert on an unknown id is a no-op (record undefined branch).
|
|
expect(() =>
|
|
internal.upsertReceiverTask({ taskId: "missing", status: "completed" }),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
});
|