inspector/clients/web/src/test/integration/mcp/inspectorClient.test.ts
Cliff Hall 53b619db09
Merge pull request #1958 from modelcontextprotocol/v2/fix/unhandled-rejections-progress-tests
fix: settle the deliberately in-flight tool calls in the progress tests
2026-08-11 11:17:55 -04:00

5837 lines
197 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { resolve } from "node:path";
import * as z from "zod/v4";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import {
MessageLogState,
FetchRequestLogState,
StderrLogState,
PagedResourcesState,
PagedResourceTemplatesState,
PagedPromptsState,
ManagedResourcesState,
ManagedPromptsState,
ManagedToolsState,
} from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
import { SamplingCreateMessage } from "@inspector/core/mcp/samplingCreateMessage.js";
import { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage.js";
import {
getTestMcpServerCommand,
createTestServerHttp,
type TestServerHttp,
waitForEvent,
waitForProgressCount,
createEchoTool,
createTestServerInfo,
createFileResourceTemplate,
createCollectSampleTool,
createCollectFormElicitationTool,
createCollectUrlElicitationTool,
createUrlElicitationFormTool,
createSendNotificationTool,
createSendProgressTool,
createListRootsTool,
createArgsPrompt,
createNumberedTools,
createNumberedResources,
createNumberedResourceTemplates,
createNumberedPrompts,
getTaskServerConfig,
createElicitationTaskTool,
createSamplingTaskTool,
createProgressTaskTool,
createTaskTool,
createAddResourceTool,
createAddToolTool,
createAddPromptTool,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
import type {
MessageEntry,
ConnectionStatus,
FetchRequestEntryBase,
} from "@inspector/core/mcp/types.js";
import type { JsonValue } from "@inspector/core/json/jsonUtils.js";
import type {
TypedEvent,
TaskWithOptionalCreatedAt,
} from "@inspector/core/mcp/inspectorClientEventTarget.js";
import type {
CreateMessageResult,
ElicitResult,
CallToolResult,
Task,
Tool,
Resource,
ResourceTemplateType as ResourceTemplate,
Prompt,
Progress,
ContentBlock,
} from "@modelcontextprotocol/client";
import {
LOG_LEVEL_META_KEY,
RELATED_TASK_META_KEY,
SdkError,
SdkErrorCode,
} from "@modelcontextprotocol/client";
/** Get all tools from the client via listTools() (paginates if needed). */
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;
}
/** Get a tool by name from the client via listTools() (paginates if needed). */
async function getTool(client: InspectorClient, name: string): Promise<Tool> {
const tool = (await getAllTools(client)).find((t) => t.name === name);
if (tool) return tool;
throw new Error(`Tool ${name} not found`);
}
/**
* Hold a deliberately un-awaited in-flight call so its rejection is handled.
*
* A few tests start a tool call, assert on the notifications it streams, and
* then tear the connection down while the call is still in flight. That is a
* legitimate thing to exercise, but `disconnect()` closes the SDK client, which
* rejects every pending request with "Connection closed" — and a floating
* promise makes that an *unhandled* rejection, which vitest counts as a run
* error and fails `npm run ci` even though every test passes (#1947).
*
* Attach the handler at call time (not after the assertions) so there is no
* window in which the rejection can escape, then finish through
* `disconnectAndSettle()`, which tears down and awaits the call in one step.
*
* Only a `CONNECTION_CLOSED` raised *by that teardown* is absorbed. Plain
* fulfillment is fine too — whether the call beats the teardown is a race, so
* asserting either outcome would turn this straight back into a flake. Every
* other rejection is re-thrown, including a `CONNECTION_CLOSED` that arrives
* before teardown begins: a transport that drops on its own after emitting the
* progress notifications is a real regression, and absorbing it would let these
* tests pass on the strength of the notifications alone.
*
* The teardown flag is owned by the helper and set inside `disconnectAndSettle`
* rather than by the caller, so the flag cannot be raised too early (which would
* reopen the hole) and the await cannot be forgotten.
*/
interface InFlightCall {
/** Disconnect, then await the call — absorbing only this teardown's close. */
disconnectAndSettle(client: InspectorClient): Promise<void>;
}
function settleInFlight(call: Promise<unknown>): InFlightCall {
let tearingDown = false;
const settled = call.then(
() => undefined,
(error: unknown) => {
if (
tearingDown &&
error instanceof SdkError &&
error.code === SdkErrorCode.ConnectionClosed
) {
return;
}
throw error;
},
);
// `then` returns a *derived* promise, and the re-throw above rejects that one
// — not `call`. The caller does not await it until after `disconnect()`, so an
// unexpected rejection arriving while the test is still waiting on progress
// notifications would sit unobserved for seconds and be reported as an
// unhandled rejection: precisely the failure this helper exists to prevent.
// Observe it the moment it exists. This does not swallow anything — `settled`
// stays rejected, so the caller's `await` below still fails the test.
settled.catch(() => undefined);
return {
async disconnectAndSettle(client: InspectorClient): Promise<void> {
tearingDown = true;
await client.disconnect();
await settled;
},
};
}
/** Get all resources from the client via listResources() (paginates if needed). */
async function getAllResources(
client: InspectorClient,
metadata?: Record<string, string>,
): Promise<Resource[]> {
const collected: Resource[] = [];
let cursor: string | undefined;
for (let i = 0; i < 100; i++) {
const r = await client.listResources(cursor, metadata);
collected.push(...r.resources);
cursor = r.nextCursor;
if (!cursor) break;
}
return collected;
}
/** Get all resource templates via listResourceTemplates() (paginates if needed). */
async function getAllResourceTemplates(
client: InspectorClient,
metadata?: Record<string, string>,
): Promise<ResourceTemplate[]> {
const collected: ResourceTemplate[] = [];
let cursor: string | undefined;
for (let i = 0; i < 100; i++) {
const r = await client.listResourceTemplates(cursor, metadata);
collected.push(...r.resourceTemplates);
cursor = r.nextCursor;
if (!cursor) break;
}
return collected;
}
/** Get all prompts via listPrompts() (paginates if needed). */
async function getAllPrompts(
client: InspectorClient,
metadata?: Record<string, string>,
): Promise<Prompt[]> {
const collected: Prompt[] = [];
let cursor: string | undefined;
for (let i = 0; i < 100; i++) {
const r = await client.listPrompts(cursor, metadata);
collected.push(...r.prompts);
cursor = r.nextCursor;
if (!cursor) break;
}
return collected;
}
/** Minimal Tool shape for tests that need to call a tool by name (e.g. server returns "not found"). */
function minimalTool(name: string): Tool {
return { name, description: "", inputSchema: { type: "object" } };
}
describe("InspectorClient", () => {
let client: InspectorClient | null;
let server: TestServerHttp | null;
let serverCommand: { command: string; args: string[] };
beforeEach(() => {
serverCommand = getTestMcpServerCommand();
server = null;
});
afterEach(async () => {
// Orderly teardown: disconnect client first, then stop server.
// HTTP test server sets closing before close so in-flight progress tools skip sending.
if (client) {
try {
await client.disconnect();
} catch {
// Ignore disconnect errors
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// Ignore server stop errors
}
server = null;
}
});
describe("Connection Management", () => {
it("should create client with stdio transport", () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
expect(client.getStatus()).toBe("disconnected");
expect(client.getServerType()).toBe("stdio");
});
it("should connect to server", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
expect(client.getStatus()).toBe("connected");
});
it("should disconnect from server", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
expect(client.getStatus()).toBe("connected");
await client.disconnect();
expect(client.getStatus()).toBe("disconnected");
});
it("should clear server state on disconnect", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const pagedResourcesState = new PagedResourcesState(client);
const pagedPromptsState = new PagedPromptsState(client);
await client.connect();
expect((await client.listTools()).tools.length).toBeGreaterThan(0);
await pagedResourcesState.loadPage();
await pagedPromptsState.loadPage();
expect(pagedResourcesState.getResources().length).toBeGreaterThan(0);
expect(pagedPromptsState.getPrompts().length).toBeGreaterThan(0);
await client.disconnect();
expect(pagedResourcesState.getResources().length).toBe(0);
expect(pagedPromptsState.getPrompts().length).toBe(0);
pagedResourcesState.destroy();
pagedPromptsState.destroy();
});
it("MessageLogState clears across a disconnect → reconnect cycle", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await getAllTools(client);
const firstConnectMessages = messageLogState.getMessages();
expect(firstConnectMessages.length).toBeGreaterThan(0);
await client.disconnect();
await client.connect();
await getAllTools(client);
const secondConnectMessages = messageLogState.getMessages();
expect(secondConnectMessages.length).toBeGreaterThan(0);
if (firstConnectMessages.length > 0 && secondConnectMessages.length > 0) {
const lastFirstMessage =
firstConnectMessages[firstConnectMessages.length - 1];
const firstSecondMessage = secondConnectMessages[0];
if (lastFirstMessage && firstSecondMessage) {
expect(firstSecondMessage.timestamp.getTime()).toBeGreaterThanOrEqual(
lastFirstMessage.timestamp.getTime(),
);
}
}
messageLogState.destroy();
});
it("rejects connect() with a timeout error when serverSettings.connectionTimeout fires", async () => {
// Stub transport whose start() never resolves — simulates a slow /
// unreachable upstream. InspectorClient.connect() should race against
// serverSettings.connectionTimeout and reject with a descriptive error;
// status should end up in "error", and the client should have
// internally torn down the transport (next connect() must rebuild).
const hangingTransport = {
start: () => new Promise<void>(() => {}),
send: async () => {},
close: async () => {},
onclose: undefined,
onerror: undefined,
onmessage: undefined,
sessionId: undefined,
};
const fakeFactory = () => ({
transport:
hangingTransport as unknown as import("@modelcontextprotocol/client").Transport,
});
client = new InspectorClient(
{ type: "streamable-http", url: "http://localhost:1/never" },
{
environment: { transport: fakeFactory },
serverSettings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 50,
requestTimeout: 0,
taskTtl: 0,
maxFetchRequests: 1000,
roots: [],
},
},
);
const before = Date.now();
await expect(client.connect()).rejects.toThrow(
/Connection timed out after 50 ms/,
);
const elapsed = Date.now() - before;
// Sanity: the race should fire near the configured timeout, not at
// some far-future SDK default.
expect(elapsed).toBeLessThan(2000);
// connect() rejected → the outer catch transitions status to "error"
// (the same end state any other handshake failure would produce).
expect(client.getStatus()).toBe("error");
});
it("holds status at connecting when connect fails with a recoverable 401", async () => {
const unauthorizedTransport = {
start: async () => {
const err = new Error("Unauthorized") as Error & { status?: number };
err.status = 401;
throw err;
},
send: async () => {},
close: async () => {},
onclose: undefined,
onerror: undefined,
onmessage: undefined,
sessionId: undefined,
};
const fakeFactory = () => ({
transport:
unauthorizedTransport as unknown as import("@modelcontextprotocol/client").Transport,
});
client = new InspectorClient(
{ type: "streamable-http", url: "http://localhost:8081/mcp" },
{ environment: { transport: fakeFactory } },
);
await expect(client.connect()).rejects.toMatchObject({ status: 401 });
expect(client.getStatus()).toBe("connecting");
});
});
describe("Message Tracking", () => {
it("should track requests (via MessageLogState)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await getAllTools(client);
const messages = messageLogState.getMessages();
expect(messages.length).toBeGreaterThan(0);
const request = messages.find((m) => m.direction === "request");
expect(request).toBeDefined();
if (request) {
expect("method" in request.message).toBe(true);
}
messageLogState.destroy();
});
it("should track responses (via MessageLogState)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await getAllTools(client);
const messages = messageLogState.getMessages();
const request = messages.find((m) => m.direction === "request");
expect(request).toBeDefined();
if (request && "response" in request) {
expect(request.response).toBeDefined();
expect(request.duration).toBeDefined();
}
messageLogState.destroy();
});
it("should emit message events", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const messageEvents: MessageEntry[] = [];
client.addEventListener("message", (event) => {
messageEvents.push(event.detail);
});
await client.connect();
await getAllTools(client);
expect(messageEvents.length).toBeGreaterThan(0);
});
it("MessageLogState getMessages(predicate) returns only matching entries", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await getAllTools(client);
const all = messageLogState.getMessages();
expect(all.length).toBeGreaterThan(0);
const requests = messageLogState.getMessages(
(m) => m.direction === "request",
);
expect(requests.length).toBeLessThanOrEqual(all.length);
expect(requests.every((m) => m.direction === "request")).toBe(true);
const notifications = messageLogState.getMessages(
(m) => m.direction === "notification",
);
expect(notifications.every((m) => m.direction === "notification")).toBe(
true,
);
messageLogState.destroy();
});
it("matches responses to requests when a sibling listener fires a request inside the same connect event (regression for unmatched */list responses)", async () => {
// Reproduces the pre-fix bug where MessageLogState's clear-on-connect
// listener ran after sibling state-manager onConnect listeners had
// already synchronously tracked their initial list requests. That clear
// wiped pendingRequestEntries before the responses arrived, leaving
// the history with unmatched "response" entries marked PENDING.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
const messageLogState = new MessageLogState(client);
// Simulate the App.tsx wiring: ManagedToolsState et al. register their
// own connect listeners that synchronously fire `void client.listTools()`.
const refreshClient = client;
client.addEventListener("connect", () => {
void refreshClient.listTools();
});
await client.connect();
// Settle: drain pending microtasks so the listTools response can fold.
await new Promise((r) => setTimeout(r, 100));
const requests = messageLogState
.getMessages()
.filter((m) => m.direction === "request");
const orphanResponses = messageLogState
.getMessages()
.filter((m) => m.direction === "response");
const listToolsReq = requests.find(
(m) => (m.message as { method?: string }).method === "tools/list",
);
expect(listToolsReq).toBeDefined();
// Its response must be folded into the request entry, not pushed as a
// separate "response" entry.
expect(listToolsReq?.response).toBeDefined();
expect(listToolsReq?.duration).toBeGreaterThanOrEqual(0);
expect(orphanResponses).toEqual([]);
messageLogState.destroy();
});
});
describe("Fetch Request Tracking", () => {
it("should track HTTP requests for SSE transport", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
serverType: "sse",
});
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
const fetchRequestLogState = new FetchRequestLogState(client);
await client.connect();
await getAllTools(client);
const fetchRequests = fetchRequestLogState.getFetchRequests();
expect(fetchRequests.length).toBeGreaterThan(0);
const request = fetchRequests[0];
expect(request).toBeDefined();
if (request) {
expect(request.url).toContain("/sse");
expect(request.method).toBe("GET");
expect(request.category).toBe("transport");
}
fetchRequestLogState.destroy();
});
it("should track HTTP requests for streamable-http transport", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
const fetchRequestLogState = new FetchRequestLogState(client);
await client.connect();
await getAllTools(client);
const fetchRequests = fetchRequestLogState.getFetchRequests();
expect(fetchRequests.length).toBeGreaterThan(0);
const request = fetchRequests[0];
expect(request).toBeDefined();
if (request) {
expect(request.url).toContain("/mcp");
expect(request.method).toBe("POST");
expect(request.category).toBe("transport");
}
fetchRequestLogState.destroy();
});
it("should track request and response details", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
const fetchRequestLogState = new FetchRequestLogState(client);
await client.connect();
await getAllTools(client);
const fetchRequests = fetchRequestLogState.getFetchRequests();
expect(fetchRequests.length).toBeGreaterThan(0);
const request = fetchRequests.find((r) => r.responseStatus !== undefined);
expect(request).toBeDefined();
if (request) {
expect(request.requestHeaders).toBeDefined();
expect(request.responseStatus).toBeDefined();
expect(request.responseHeaders).toBeDefined();
expect(request.duration).toBeDefined();
expect(request.category).toBe("transport");
}
fetchRequestLogState.destroy();
});
it("should emit fetchRequest events", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
const fetchRequestEvents: FetchRequestEntryBase[] = [];
client.addEventListener("fetchRequest", (event) => {
fetchRequestEvents.push(event.detail);
});
await client.connect();
await getAllTools(client);
expect(fetchRequestEvents.length).toBeGreaterThan(0);
});
it("should emit fetchRequest events", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
const entries: unknown[] = [];
client.addEventListener("fetchRequest", (e) => {
entries.push((e as CustomEvent).detail);
});
await client.connect();
await getAllTools(client);
expect(entries.length).toBeGreaterThan(0);
});
});
describe("Server Data Management", () => {
it("getServerSettings returns the constructor settings; setServerSettings replaces them live (#1444)", () => {
const initial = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
maxFetchRequests: 1000,
autoRefreshOnListChanged: false,
roots: [],
};
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
serverSettings: initial,
},
);
expect(client.getServerSettings()?.autoRefreshOnListChanged).toBe(false);
client.setServerSettings({ ...initial, autoRefreshOnListChanged: true });
expect(client.getServerSettings()?.autoRefreshOnListChanged).toBe(true);
});
it("should auto-fetch server contents when enabled", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
expect((await client.listTools()).tools.length).toBeGreaterThan(0);
expect(client.getCapabilities()).toBeDefined();
expect(client.getServerInfo()).toBeDefined();
});
it("exposes the negotiated protocol version after connect (stdio)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
expect(client.getProtocolVersion()).toBeUndefined();
await client.connect();
// The SDK Client's getNegotiatedProtocolVersion() supplies the version
// for both eras.
expect(client.getProtocolVersion()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// A legacy connect reports the legacy era; there is no server/discover
// result without a probe.
expect(client.getProtocolEra()).toBe("legacy");
expect(client.getDiscoverResult()).toBeUndefined();
await client.disconnect();
expect(client.getProtocolVersion()).toBeUndefined();
expect(client.getProtocolEra()).toBeUndefined();
expect(client.getDiscoverResult()).toBeUndefined();
});
it("negotiates the legacy era under versionNegotiation 'auto' against a legacy server (stdio)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
// Auto probes server/discover, then falls back to the initialize
// handshake on this legacy test server — so the negotiated era is
// legacy and getProtocolEra() reports it (vs. undefined on a plain
// legacy connect).
versionNegotiation: { mode: "auto" },
},
);
await client.connect();
expect(client.getProtocolEra()).toBe("legacy");
expect(client.getProtocolVersion()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// A legacy server has no server/discover result even when probed.
expect(client.getDiscoverResult()).toBeUndefined();
await client.disconnect();
});
it("should not auto-fetch server contents when disabled", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Client no longer stores tools; listTools() still returns server tools when called
expect((await client.listTools()).tools.length).toBeGreaterThan(0);
});
it("managed list states populate on connect (regression: capability gate must see capabilities before the connect event)", async () => {
// Regression for #1395 + connect-ordering: the managed list-state managers
// refresh on the "connect" event and gate their list RPC on
// getCapabilities(). If "connect" is dispatched before fetchServerInfo()
// populates capabilities, the synchronous gate reads undefined and wipes
// the list to empty — tools/prompts/resources all vanish on every connect.
// The single fix is shared by all the managed states, so we cover tools
// and resources (two distinct capabilities) to guard against a future
// change gating only one primitive differently.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
const toolsState = new ManagedToolsState(client);
const resourcesState = new ManagedResourcesState(client);
// Await the change events the connect-triggered refresh() emits rather than
// a fixed sleep — refresh() is async past its synchronous capability gate.
const toolsChanged = waitForEvent(toolsState, "toolsChange");
const resourcesChanged = waitForEvent(resourcesState, "resourcesChange");
await client.connect();
await Promise.all([toolsChanged, resourcesChanged]);
expect(client.getCapabilities()?.tools).toBeDefined();
expect(toolsState.getTools().length).toBeGreaterThan(0);
expect(client.getCapabilities()?.resources).toBeDefined();
expect(resourcesState.getResources().length).toBeGreaterThan(0);
toolsState.destroy();
resourcesState.destroy();
});
});
describe("Tool Methods", () => {
beforeEach(async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
});
it("should list tools", async () => {
const result = await client!.listTools();
expect(Array.isArray(result.tools)).toBe(true);
expect(result.tools.length).toBeGreaterThan(0);
});
it("should call tool with string arguments", async () => {
const tool = await getTool(client!, "echo");
const result = await client!.callTool(tool, {
message: "hello world",
});
expect(result).toHaveProperty("result");
expect(result.success).toBe(true);
expect(result.result).toHaveProperty("content");
const content = result.result!.content as ContentBlock[];
expect(Array.isArray(content)).toBe(true);
expect(content[0]).toHaveProperty("type", "text");
expect("text" in content[0] && content[0].text).toContain("hello world");
});
it("should call tool with number arguments", async () => {
const tool = await getTool(client!, "get_sum");
const result = await client!.callTool(tool, {
a: 42,
b: 58,
});
expect(result.success).toBe(true);
expect(result.result).toHaveProperty("content");
const content = result.result!.content as ContentBlock[];
const resultData = JSON.parse(
"text" in content[0] ? content[0].text : "",
);
expect(resultData.result).toBe(100);
});
it("should call tool with boolean arguments", async () => {
const tool = await getTool(client!, "get_annotated_message");
const result = await client!.callTool(tool, {
messageType: "success",
includeImage: true,
});
expect(result.result).toHaveProperty("content");
const content = result.result!.content as ContentBlock[];
expect(content.length).toBeGreaterThan(1);
const hasImage = content.some(
(item: ContentBlock) => "type" in item && item.type === "image",
);
expect(hasImage).toBe(true);
});
it("should return both content and structuredContent for tool with outputSchema (get_temp)", async () => {
const tool = await getTool(client!, "get_temp");
const result = await client!.callTool(tool, {
city: "Seattle",
units: "C",
});
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result).toHaveProperty("content");
expect(result.result).toHaveProperty("structuredContent");
const content = result.result!.content as Array<{
type: string;
text?: string;
}>;
expect(Array.isArray(content)).toBe(true);
expect(content[0].type).toBe("text");
expect(content[0].text).toContain("Seattle");
expect(content[0].text).toContain("25");
expect(content[0].text).toContain("degrees C");
const structured = result.result!.structuredContent as Record<
string,
unknown
>;
expect(structured).toEqual({
temperature: 25,
unit: "C",
city: "Seattle",
});
});
it("throws on output-schema validation when structuredContent has extra properties (get_temp_extra)", async () => {
// get_temp_extra returns an undeclared `extra` property. The SDK client
// validates structuredContent against the strict output schema and
// throws, so the default path delivers no result to the caller.
const tool = await getTool(client!, "get_temp_extra");
await expect(
client!.callTool(tool, { city: "Oslo", units: "C" }),
).rejects.toThrow(/output schema|additional propert/i);
});
it("delivers the raw result when skipOutputValidation is set (get_temp_extra)", async () => {
// The MCP Apps passthrough path bypasses host-side output validation so a
// schema-violating-but-real result still reaches the app.
const tool = await getTool(client!, "get_temp_extra");
const result = await client!.callTool(
tool,
{ city: "Oslo", units: "C" },
undefined,
undefined,
undefined,
{ skipOutputValidation: true },
);
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
const structured = result.result!.structuredContent as Record<
string,
unknown
>;
expect(structured).toMatchObject({
temperature: 25,
unit: "C",
city: "Oslo",
extra: "undeclared",
});
// Non-fatal advisory: the result was delivered, but the mismatch is
// reported so callers can warn that strict clients would reject it.
expect(result.outputValidationError ?? "").toMatch(
/additional propert|output schema|schema/i,
);
});
it("does not set outputValidationError when the result matches the schema (get_temp)", async () => {
const tool = await getTool(client!, "get_temp");
const result = await client!.callTool(
tool,
{ city: "Oslo", units: "C" },
undefined,
undefined,
undefined,
{ skipOutputValidation: true },
);
expect(result.success).toBe(true);
expect(result.outputValidationError).toBeUndefined();
});
it("should handle tool not found", async () => {
// SDK v2 change (#1624): an unknown-tool call now *rejects* with a
// ProtocolError (-32602) instead of resolving an `isError: true` result.
// callTool records the failed call and rethrows, so the caller sees a
// rejection whose message names the missing tool.
await expect(
client!.callTool(minimalTool("nonexistent-tool"), {}),
).rejects.toThrow(/not found|unknown tool|nonexistent-tool/i);
});
it("cancelToolCall() is a no-op (returns false) when no call is in flight", () => {
expect(client!.cancelToolCall()).toBe(false);
});
it("ToolCallCancelledError carries the tool name when known, and reads generically without one", () => {
expect(new ToolCallCancelledError("echo").message).toContain('"echo"');
expect(new ToolCallCancelledError().message).toBe(
"Tool call was cancelled.",
);
});
it("cancelToolCall() aborts the in-flight call: rejects with ToolCallCancelledError, and records no failed call", async () => {
const tool = await getTool(client!, "echo");
let failedCallCount = 0;
client!.addEventListener("toolCallResultChange", (event) => {
if (!event.detail.success) failedCallCount++;
});
// Start the call, then cancel synchronously — before the response can be
// processed — so the abort always wins the race regardless of tool speed.
const promise = client!.callTool(tool, { message: "hello world" });
expect(client!.cancelToolCall()).toBe(true);
await expect(promise).rejects.toBeInstanceOf(ToolCallCancelledError);
// SDK v2 change (spec §9.2): cancellation now surfaces as a stream/connection
// abort rather than a guaranteed `notifications/cancelled` frame. v2's
// `callTool` awaits output-validator compilation before it issues the
// `tools/call` request, so a synchronous cancel aborts the shared signal
// *before* that request registers — the SDK then takes its pre-aborted
// early-reject path and emits no cancellation frame. The user-facing
// behavior we care about is unchanged: the call rejects with
// ToolCallCancelledError and is not recorded as a failed call.
// Cancelling is intentional, so it is not recorded as a failed tool call.
expect(failedCallCount).toBe(0);
// The controller was cleared, so a second cancel is a no-op.
expect(client!.cancelToolCall()).toBe(false);
});
it("a disconnect mid-call does not surface as a ToolCallCancelledError", async () => {
// disconnect() aborts the same in-flight controller, but with a different
// reason than cancelToolCall(). The call must reject as an ordinary error,
// not a user-cancel — so App doesn't show a misleading "cancelled" toast.
// Use a genuinely slow tool (over HTTP) so the call is still in flight when
// we disconnect; echo would complete before teardown.
await client!.disconnect();
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const tool = await getTool(client, "send_progress");
// 20 units * 200ms ≈ 4s — comfortably still running when we disconnect.
const promise = client.callTool(tool, { units: 20, delayMs: 200 });
await new Promise((resolve) => setTimeout(resolve, 150));
await client.disconnect();
let caught: unknown;
try {
await promise;
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
expect(caught).not.toBeInstanceOf(ToolCallCancelledError);
});
it("should paginate tools when maxPageSize is set", async () => {
// Disconnect and create a new server with pagination
await client!.disconnect();
if (server) {
await server.stop();
}
// Create server with 10 tools and page size of 3
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: createNumberedTools(10),
maxPageSize: {
tools: 3,
},
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
// First page should have 3 tools
const page1 = await client.listTools();
expect(page1.tools.length).toBe(3);
expect(page1.nextCursor).toBeDefined();
expect(page1.tools[0]?.name).toBe("tool_1");
expect(page1.tools[1]?.name).toBe("tool_2");
expect(page1.tools[2]?.name).toBe("tool_3");
// Second page should have 3 more tools
const page2 = await client.listTools(page1.nextCursor);
expect(page2.tools.length).toBe(3);
expect(page2.nextCursor).toBeDefined();
expect(page2.tools[0]?.name).toBe("tool_4");
expect(page2.tools[1]?.name).toBe("tool_5");
expect(page2.tools[2]?.name).toBe("tool_6");
// Third page should have 3 more tools
const page3 = await client.listTools(page2.nextCursor);
expect(page3.tools.length).toBe(3);
expect(page3.nextCursor).toBeDefined();
expect(page3.tools[0]?.name).toBe("tool_7");
expect(page3.tools[1]?.name).toBe("tool_8");
expect(page3.tools[2]?.name).toBe("tool_9");
// Fourth page should have 1 tool and no next cursor
const page4 = await client.listTools(page3.nextCursor);
expect(page4.tools.length).toBe(1);
expect(page4.nextCursor).toBeUndefined();
expect(page4.tools[0]?.name).toBe("tool_10");
});
it("listAllTools aggregates every page via the SDK's cache-aware verb (#1721)", async () => {
// Disconnect and create a new server with pagination
await client!.disconnect();
if (server) {
await server.stop();
}
// 10 tools, page size 3 → 4 wire pages the SDK walks internally.
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: createNumberedTools(10),
maxPageSize: { tools: 3 },
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
// One call returns the fully-aggregated list (no per-page cursor
// surfaced), unlike the single-page listTools() above. `cacheMode:
// "refresh"` forces a wire fetch — a no-op here (no ttlMs hints) but the
// path the managed refresh uses.
const all = await client.listAllTools({ cacheMode: "refresh" });
expect(all.tools.map((t) => t.name)).toEqual(
Array.from({ length: 10 }, (_, i) => `tool_${i + 1}`),
);
});
it("listAllResources / listAllPrompts / listAllResourceTemplates aggregate too (#1721)", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: createNumberedTools(2),
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
// Exercises the remaining three aggregate methods (empty is fine — the
// point is the SDK verb runs and returns a well-formed shape). Default
// cacheMode ('use') for these.
const resources = await client.listAllResources();
const prompts = await client.listAllPrompts({ metadata: { k: "v" } });
const templates = await client.listAllResourceTemplates();
expect(Array.isArray(resources.resources)).toBe(true);
expect(Array.isArray(prompts.prompts)).toBe(true);
expect(Array.isArray(templates.resourceTemplates)).toBe(true);
});
});
describe("Structured output showcase config (#1908)", () => {
// Drives the shipped `structured-output-http.json` end to end — the file
// itself, the `list_items` preset registration, and the fixture's nested
// payload. A typo in any of the three fails here rather than leaving the
// documented showcase quietly broken.
const showcaseConfigPath = resolve(
import.meta.dirname,
"../../../../../../test-servers/configs/structured-output-http.json",
);
it("serves list_items with a summary block and a nested structuredContent", async () => {
server = createTestServerHttp(
resolveConfig(loadConfig(showcaseConfigPath)),
);
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
const tool = await getTool(client, "list_items");
expect(tool.outputSchema).toBeDefined();
const result = await client.callTool(tool, {});
expect(result.success).toBe(true);
// The text block only summarizes — the payload is the structured half.
const content = result.result!.content as Array<{
type: string;
text?: string;
}>;
expect(content[0].type).toBe("text");
expect(content[0].text).toBe("Found 2 items.");
expect(result.result!.structuredContent).toEqual({
items: [
{ id: 1, name: "Item A", tags: ["foo", "bar"] },
{ id: 2, name: "Item B", tags: ["baz"] },
],
total: 2,
});
});
});
describe("Default metadata (server-wide _meta)", () => {
function metaOf(req: { message: unknown }): Record<string, unknown> {
const params = (req.message as { params?: { _meta?: unknown } }).params;
return (params?._meta as Record<string, unknown>) ?? {};
}
it("merges defaultMetadata into the _meta of outgoing tools/list and tools/call", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
defaultMetadata: { tenant: "acme", env: "prod" },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const tool = await getTool(client, "echo");
await client.callTool(tool, { message: "hi" });
const requests = messageLogState
.getMessages()
.filter((m) => m.direction === "request");
const listToolsReq = requests.find(
(m) => (m.message as { method?: string }).method === "tools/list",
);
const callToolReq = requests.find(
(m) => (m.message as { method?: string }).method === "tools/call",
);
expect(listToolsReq).toBeDefined();
expect(callToolReq).toBeDefined();
expect(metaOf(listToolsReq!)).toMatchObject({
tenant: "acme",
env: "prod",
});
expect(metaOf(callToolReq!)).toMatchObject({
tenant: "acme",
env: "prod",
});
messageLogState.destroy();
});
it("call-time metadata overrides defaultMetadata on key collision", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
defaultMetadata: { tenant: "acme" },
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
const tool = await getTool(client, "echo");
await client.callTool(tool, { message: "hi" }, { tenant: "override" });
const callToolReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/call",
);
expect(callToolReq).toBeDefined();
expect(metaOf(callToolReq!).tenant).toBe("override");
messageLogState.destroy();
});
it("does not inject defaults into _meta when defaultMetadata is unset", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
// defaultMetadata omitted on purpose
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const listToolsReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
expect(listToolsReq).toBeDefined();
// The SDK auto-injects a `progressToken` for progress-tracked requests
// — that's an SDK concern, not user metadata. Assert only that none of
// our example default keys leak through when defaultMetadata is unset.
const meta = metaOf(listToolsReq!);
expect(meta.tenant).toBeUndefined();
expect(meta.env).toBeUndefined();
messageLogState.destroy();
});
});
describe("Resource Methods", () => {
beforeEach(async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
});
it("should list resources", async () => {
const resources = await getAllResources(client!);
expect(Array.isArray(resources)).toBe(true);
});
it("should read resource", async () => {
const resources = await getAllResources(client!);
if (resources.length > 0) {
const uri = resources[0]!.uri;
const readResult = await client!.readResource(uri);
expect(readResult).toHaveProperty("result");
expect(readResult.result).toHaveProperty("contents");
}
});
it("should paginate resources when maxPageSize is set", async () => {
// Disconnect and create a new server with pagination
await client!.disconnect();
if (server) {
await server.stop();
}
// Create server with 10 resources and page size of 3
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(10),
maxPageSize: {
resources: 3,
},
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
// First page should have 3 resources
const page1 = await client.listResources();
expect(page1.resources.length).toBe(3);
expect(page1.nextCursor).toBeDefined();
expect(page1.resources[0]?.uri).toBe("test://resource_1");
expect(page1.resources[1]?.uri).toBe("test://resource_2");
expect(page1.resources[2]?.uri).toBe("test://resource_3");
// Second page should have 3 more resources
const page2 = await client.listResources(page1.nextCursor);
expect(page2.resources.length).toBe(3);
expect(page2.nextCursor).toBeDefined();
expect(page2.resources[0]?.uri).toBe("test://resource_4");
expect(page2.resources[1]?.uri).toBe("test://resource_5");
expect(page2.resources[2]?.uri).toBe("test://resource_6");
// Third page should have 3 more resources
const page3 = await client.listResources(page2.nextCursor);
expect(page3.resources.length).toBe(3);
expect(page3.nextCursor).toBeDefined();
expect(page3.resources[0]?.uri).toBe("test://resource_7");
expect(page3.resources[1]?.uri).toBe("test://resource_8");
expect(page3.resources[2]?.uri).toBe("test://resource_9");
// Fourth page should have 1 resource and no next cursor
const page4 = await client.listResources(page3.nextCursor);
expect(page4.resources.length).toBe(1);
expect(page4.nextCursor).toBeUndefined();
expect(page4.resources[0]?.uri).toBe("test://resource_10");
const allResources = await getAllResources(client);
expect(allResources.length).toBe(10);
});
it("should suppress events during listAllResources pagination and emit final event", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(6),
maxPageSize: {
resources: 2,
},
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const managedState = new ManagedResourcesState(client);
const events: Resource[][] = [];
managedState.addEventListener("resourcesChange", (e) => {
events.push(e.detail);
});
await managedState.refresh();
expect(managedState.getResources().length).toBe(6);
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(6);
managedState.destroy();
});
it("should accumulate resources when paginating with cursor", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(6),
maxPageSize: { resources: 2 },
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedResourcesState(client);
expect(pagedState.getResources().length).toBe(0);
const page1 = await pagedState.loadPage();
expect(page1.resources.length).toBe(2);
expect(pagedState.getResources().length).toBe(2);
expect(pagedState.getResources()[0]?.uri).toBe("test://resource_1");
expect(pagedState.getResources()[1]?.uri).toBe("test://resource_2");
const page2 = await pagedState.loadPage(page1.nextCursor);
expect(page2.resources.length).toBe(2);
expect(pagedState.getResources().length).toBe(4);
expect(pagedState.getResources()[2]?.uri).toBe("test://resource_3");
expect(pagedState.getResources()[3]?.uri).toBe("test://resource_4");
const page3 = await pagedState.loadPage(page2.nextCursor);
expect(page3.resources.length).toBe(2);
expect(pagedState.getResources().length).toBe(6);
expect(pagedState.getResources()[4]?.uri).toBe("test://resource_5");
expect(pagedState.getResources()[5]?.uri).toBe("test://resource_6");
const page1Again = await pagedState.loadPage();
expect(page1Again.resources.length).toBe(2);
expect(pagedState.getResources().length).toBe(2);
expect(pagedState.getResources()[0]?.uri).toBe("test://resource_1");
pagedState.destroy();
});
it("should emit resourcesChange events when paginating", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(6),
maxPageSize: { resources: 2 },
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedResourcesState(client);
const events: Resource[][] = [];
pagedState.addEventListener("resourcesChange", (e) => {
events.push(e.detail);
});
const page1 = await pagedState.loadPage();
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(2);
await pagedState.loadPage(page1.nextCursor);
expect(events.length).toBe(2);
expect(events[1]!.length).toBe(4);
pagedState.destroy();
});
it("should emit resourcesChange when loading pages via PagedResourcesState", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(6),
maxPageSize: { resources: 2 },
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedResourcesState(client);
const events: Resource[][] = [];
pagedState.addEventListener("resourcesChange", (e) => {
events.push(e.detail);
});
await pagedState.loadPage();
expect(pagedState.getResources().length).toBe(2);
expect(events.length).toBe(1);
pagedState.destroy();
});
it("should clear resources and emit event", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(3),
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedResourcesState(client);
await pagedState.loadPage();
expect(pagedState.getResources().length).toBe(3);
const events: Resource[][] = [];
pagedState.addEventListener("resourcesChange", (e) => {
events.push(e.detail);
});
pagedState.clear();
expect(pagedState.getResources().length).toBe(0);
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(0);
pagedState.destroy();
});
});
describe("Resource Template Methods", () => {
beforeEach(async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: [createFileResourceTemplate()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
});
it("should list resource templates", async () => {
const resourceTemplates = await getAllResourceTemplates(client!);
expect(Array.isArray(resourceTemplates)).toBe(true);
expect(resourceTemplates.length).toBeGreaterThan(0);
const templates = resourceTemplates;
const fileTemplate = templates.find((t) => t.name === "file");
expect(fileTemplate).toBeDefined();
expect(fileTemplate?.uriTemplate).toBe("file:///{path}");
});
it("should read resource from template", async () => {
const templates = await getAllResourceTemplates(client!);
const fileTemplate = templates.find((t) => t.name === "file");
expect(fileTemplate).toBeDefined();
// Use a URI that matches the template pattern file:///{path}
// The path variable will be "test.txt"
const expandedUri = "file:///test.txt";
// Read the resource using the expanded URI
const readResult = await client!.readResource(expandedUri);
expect(readResult).toHaveProperty("result");
expect(readResult.result).toHaveProperty("contents");
const contents = readResult.result.contents;
expect(Array.isArray(contents)).toBe(true);
expect(contents.length).toBeGreaterThan(0);
const content = contents[0];
expect(content).toHaveProperty("uri");
if (content && "text" in content) {
expect(content.text).toContain("Mock file content for: test.txt");
}
});
it("should include resources from template list callback in listResources", async () => {
// Create a server with a resource template that has a list callback
const listCallback = async () => {
return ["file:///file1.txt", "file:///file2.txt", "file:///file3.txt"];
};
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: [
createFileResourceTemplate(undefined, listCallback),
],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const resources = await getAllResources(client);
expect(Array.isArray(resources)).toBe(true);
// Verify that the resources from the list callback are included
const uris = resources.map((r) => r.uri);
expect(uris).toContain("file:///file1.txt");
expect(uris).toContain("file:///file2.txt");
expect(uris).toContain("file:///file3.txt");
});
it("should paginate resource templates when maxPageSize is set", async () => {
// Disconnect and create a new server with pagination
await client!.disconnect();
if (server) {
await server.stop();
}
// Create server with 10 resource templates and page size of 3
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: createNumberedResourceTemplates(10),
maxPageSize: {
resourceTemplates: 3,
},
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
// First page should have 3 templates
const page1 = await client.listResourceTemplates();
expect(page1.resourceTemplates.length).toBe(3);
expect(page1.nextCursor).toBeDefined();
expect(page1.resourceTemplates[0]?.uriTemplate).toBe(
"test://template_1/{param}",
);
expect(page1.resourceTemplates[1]?.uriTemplate).toBe(
"test://template_2/{param}",
);
expect(page1.resourceTemplates[2]?.uriTemplate).toBe(
"test://template_3/{param}",
);
// Second page should have 3 more templates
const page2 = await client.listResourceTemplates(page1.nextCursor);
expect(page2.resourceTemplates.length).toBe(3);
expect(page2.nextCursor).toBeDefined();
expect(page2.resourceTemplates[0]?.uriTemplate).toBe(
"test://template_4/{param}",
);
expect(page2.resourceTemplates[1]?.uriTemplate).toBe(
"test://template_5/{param}",
);
expect(page2.resourceTemplates[2]?.uriTemplate).toBe(
"test://template_6/{param}",
);
// Third page should have 3 more templates
const page3 = await client.listResourceTemplates(page2.nextCursor);
expect(page3.resourceTemplates.length).toBe(3);
expect(page3.nextCursor).toBeDefined();
expect(page3.resourceTemplates[0]?.uriTemplate).toBe(
"test://template_7/{param}",
);
expect(page3.resourceTemplates[1]?.uriTemplate).toBe(
"test://template_8/{param}",
);
expect(page3.resourceTemplates[2]?.uriTemplate).toBe(
"test://template_9/{param}",
);
// Fourth page should have 1 template and no next cursor
const page4 = await client.listResourceTemplates(page3.nextCursor);
expect(page4.resourceTemplates.length).toBe(1);
expect(page4.nextCursor).toBeUndefined();
expect(page4.resourceTemplates[0]?.uriTemplate).toBe(
"test://template_10/{param}",
);
const allTemplates = await getAllResourceTemplates(client);
expect(allTemplates.length).toBe(10);
});
it("should accumulate resource templates when paginating with cursor", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: createNumberedResourceTemplates(6),
maxPageSize: { resourceTemplates: 2 },
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedResourceTemplatesState(client);
expect(pagedState.getResourceTemplates().length).toBe(0);
const page1 = await pagedState.loadPage();
expect(page1.resourceTemplates.length).toBe(2);
expect(pagedState.getResourceTemplates().length).toBe(2);
expect(pagedState.getResourceTemplates()[0]?.uriTemplate).toBe(
"test://template_1/{param}",
);
expect(pagedState.getResourceTemplates()[1]?.uriTemplate).toBe(
"test://template_2/{param}",
);
const page2 = await pagedState.loadPage(page1.nextCursor);
expect(page2.resourceTemplates.length).toBe(2);
expect(pagedState.getResourceTemplates().length).toBe(4);
expect(pagedState.getResourceTemplates()[2]?.uriTemplate).toBe(
"test://template_3/{param}",
);
expect(pagedState.getResourceTemplates()[3]?.uriTemplate).toBe(
"test://template_4/{param}",
);
const page3 = await pagedState.loadPage(page2.nextCursor);
expect(page3.resourceTemplates.length).toBe(2);
expect(pagedState.getResourceTemplates().length).toBe(6);
expect(pagedState.getResourceTemplates()[4]?.uriTemplate).toBe(
"test://template_5/{param}",
);
expect(pagedState.getResourceTemplates()[5]?.uriTemplate).toBe(
"test://template_6/{param}",
);
const page1Again = await pagedState.loadPage();
expect(page1Again.resourceTemplates.length).toBe(2);
expect(pagedState.getResourceTemplates().length).toBe(2);
expect(pagedState.getResourceTemplates()[0]?.uriTemplate).toBe(
"test://template_1/{param}",
);
pagedState.destroy();
});
it("should clear resource templates and emit event", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: createNumberedResourceTemplates(3),
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedResourceTemplatesState(client);
await pagedState.loadPage();
expect(pagedState.getResourceTemplates().length).toBe(3);
const events: ResourceTemplate[][] = [];
pagedState.addEventListener("resourceTemplatesChange", (e) => {
events.push(e.detail);
});
pagedState.clear();
expect(pagedState.getResourceTemplates().length).toBe(0);
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(0);
pagedState.destroy();
});
});
describe("Prompt Methods", () => {
beforeEach(async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
});
it("should list prompts", async () => {
const prompts = await getAllPrompts(client!);
expect(Array.isArray(prompts)).toBe(true);
});
it("should paginate prompts when maxPageSize is set", async () => {
// Disconnect and create a new server with pagination
await client!.disconnect();
if (server) {
await server.stop();
}
// Create server with 10 prompts and page size of 3
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: createNumberedPrompts(10),
maxPageSize: {
prompts: 3,
},
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
// First page should have 3 prompts
const page1 = await client.listPrompts();
expect(page1.prompts.length).toBe(3);
expect(page1.nextCursor).toBeDefined();
expect(page1.prompts[0]?.name).toBe("prompt_1");
expect(page1.prompts[1]?.name).toBe("prompt_2");
expect(page1.prompts[2]?.name).toBe("prompt_3");
// Second page should have 3 more prompts
const page2 = await client.listPrompts(page1.nextCursor);
expect(page2.prompts.length).toBe(3);
expect(page2.nextCursor).toBeDefined();
expect(page2.prompts[0]?.name).toBe("prompt_4");
expect(page2.prompts[1]?.name).toBe("prompt_5");
expect(page2.prompts[2]?.name).toBe("prompt_6");
// Third page should have 3 more prompts
const page3 = await client.listPrompts(page2.nextCursor);
expect(page3.prompts.length).toBe(3);
expect(page3.nextCursor).toBeDefined();
expect(page3.prompts[0]?.name).toBe("prompt_7");
expect(page3.prompts[1]?.name).toBe("prompt_8");
expect(page3.prompts[2]?.name).toBe("prompt_9");
// Fourth page should have 1 prompt and no next cursor
const page4 = await client.listPrompts(page3.nextCursor);
expect(page4.prompts.length).toBe(1);
expect(page4.nextCursor).toBeUndefined();
expect(page4.prompts[0]?.name).toBe("prompt_10");
const allPrompts = await getAllPrompts(client);
expect(allPrompts.length).toBe(10);
});
it("should suppress events during listAllPrompts pagination and emit final event", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: createNumberedPrompts(6),
maxPageSize: { prompts: 2 },
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const managedState = new ManagedPromptsState(client);
const events: Prompt[][] = [];
managedState.addEventListener("promptsChange", (e) => {
events.push(e.detail);
});
await managedState.refresh();
expect(managedState.getPrompts().length).toBe(6);
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(6);
managedState.destroy();
});
it("should accumulate prompts when paginating with cursor", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: createNumberedPrompts(6),
maxPageSize: { prompts: 2 },
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedPromptsState(client);
expect(pagedState.getPrompts().length).toBe(0);
const page1 = await pagedState.loadPage();
expect(page1.prompts.length).toBe(2);
expect(pagedState.getPrompts().length).toBe(2);
expect(pagedState.getPrompts()[0]?.name).toBe("prompt_1");
expect(pagedState.getPrompts()[1]?.name).toBe("prompt_2");
const page2 = await pagedState.loadPage(page1.nextCursor);
expect(page2.prompts.length).toBe(2);
expect(pagedState.getPrompts().length).toBe(4);
expect(pagedState.getPrompts()[2]?.name).toBe("prompt_3");
expect(pagedState.getPrompts()[3]?.name).toBe("prompt_4");
const page3 = await pagedState.loadPage(page2.nextCursor);
expect(page3.prompts.length).toBe(2);
expect(pagedState.getPrompts().length).toBe(6);
expect(pagedState.getPrompts()[4]?.name).toBe("prompt_5");
expect(pagedState.getPrompts()[5]?.name).toBe("prompt_6");
const page1Again = await pagedState.loadPage();
expect(page1Again.prompts.length).toBe(2);
expect(pagedState.getPrompts().length).toBe(2);
expect(pagedState.getPrompts()[0]?.name).toBe("prompt_1");
pagedState.destroy();
});
it("should emit promptsChange events when paginating", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: createNumberedPrompts(6),
maxPageSize: {
prompts: 2,
},
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedPromptsState(client);
const events: Prompt[][] = [];
pagedState.addEventListener("promptsChange", (e) => {
events.push(e.detail);
});
const page1 = await pagedState.loadPage();
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(2);
await pagedState.loadPage(page1.nextCursor);
expect(events.length).toBe(2);
expect(events[1]!.length).toBe(4);
pagedState.destroy();
});
it("should clear prompts and emit event", async () => {
await client!.disconnect();
if (server) {
await server.stop();
}
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: createNumberedPrompts(3),
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
},
);
await client.connect();
const pagedState = new PagedPromptsState(client);
await pagedState.loadPage();
expect(pagedState.getPrompts().length).toBe(3);
const events: Prompt[][] = [];
pagedState.addEventListener("promptsChange", (e) => {
events.push(e.detail);
});
pagedState.clear();
expect(pagedState.getPrompts().length).toBe(0);
expect(events.length).toBe(1);
expect(events[0]!.length).toBe(0);
pagedState.destroy();
});
});
describe("Progress Tracking", () => {
it("should dispatch progressNotification events when progress notifications are received", async () => {
const { createSendProgressTool } =
await import("@modelcontextprotocol/inspector-test-server");
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
progress: true,
},
);
await client.connect();
const progressToken = 12345;
const sendProgressTool = await getTool(client, "send_progress");
const inFlight = settleInFlight(
client.callTool(
sendProgressTool,
{
units: 3,
delayMs: 50,
total: 3,
message: "Test progress",
},
undefined, // generalMetadata
{ progressToken: progressToken.toString() }, // toolSpecificMetadata
),
);
const progressEvents = await waitForProgressCount(client, 3, {
timeout: 3000,
});
expect(progressEvents.length).toBe(3);
expect(progressEvents[0]).toMatchObject({
progress: 1,
total: 3,
message: "Test progress (1/3)",
progressToken: progressToken.toString(),
});
// Verify second progress event
expect(progressEvents[1]).toMatchObject({
progress: 2,
total: 3,
message: "Test progress (2/3)",
progressToken: progressToken.toString(),
});
// Verify third progress event
expect(progressEvents[2]).toMatchObject({
progress: 3,
total: 3,
message: "Test progress (3/3)",
progressToken: progressToken.toString(),
});
await inFlight.disconnectAndSettle(client!);
await server.stop();
});
it("should not dispatch progressNotification events when progress is disabled", async () => {
const { createSendProgressTool } =
await import("@modelcontextprotocol/inspector-test-server");
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
progress: false, // Disable progress
},
);
await client.connect();
const progressEvents: Progress[] = [];
const progressListener = (event: TypedEvent<"progressNotification">) => {
progressEvents.push(event.detail);
};
client.addEventListener("progressNotification", progressListener);
const progressToken = 12345;
// Call the tool with progressToken in metadata
const sendProgressTool = await getTool(client, "send_progress");
await client.callTool(
sendProgressTool,
{
units: 2,
delayMs: 50,
},
undefined, // generalMetadata
{ progressToken: progressToken.toString() }, // toolSpecificMetadata
);
// Observation window: we assert no progressNotification events; can't wait for a non-event.
await new Promise((resolve) => setTimeout(resolve, 200));
// Remove listener
client.removeEventListener("progressNotification", progressListener);
// Verify no progress events were received
expect(progressEvents.length).toBe(0);
await client!.disconnect();
await server.stop();
});
it("should handle progress notifications without total", async () => {
const { createSendProgressTool } =
await import("@modelcontextprotocol/inspector-test-server");
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
progress: true,
},
);
await client.connect();
const progressToken = 67890;
const sendProgressTool2 = await getTool(client, "send_progress");
const inFlight = settleInFlight(
client.callTool(
sendProgressTool2,
{
units: 2,
delayMs: 50,
message: "Indeterminate progress",
},
undefined, // generalMetadata
{ progressToken: progressToken.toString() }, // toolSpecificMetadata
),
);
const progressEvents = await waitForProgressCount(client, 2, {
timeout: 3000,
});
expect(progressEvents.length).toBe(2);
expect(progressEvents[0]).toMatchObject({
progress: 1,
message: "Indeterminate progress (1/2)",
progressToken: progressToken.toString(),
});
expect((progressEvents[0] as { total?: number }).total).toBeUndefined();
expect(progressEvents[1]).toMatchObject({
progress: 2,
message: "Indeterminate progress (2/2)",
progressToken: progressToken.toString(),
});
expect((progressEvents[1] as { total?: number }).total).toBeUndefined();
await inFlight.disconnectAndSettle(client!);
await server.stop();
});
it("should complete when timeout and resetTimeoutOnProgress are set (options passed through)", async () => {
const { createSendProgressTool } =
await import("@modelcontextprotocol/inspector-test-server");
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
progress: true,
timeout: 2000,
resetTimeoutOnProgress: true,
},
);
await client.connect();
const progressToken = 999;
const sendProgressTool = await getTool(client, "send_progress");
const result = await client.callTool(
sendProgressTool,
{ units: 3, delayMs: 100, total: 3, message: "Timeout test" },
undefined,
{ progressToken: progressToken.toString() },
);
expect(result.success).toBe(true);
expect((result.result as { content?: unknown[] }).content).toBeDefined();
const text = (
result.result as { content?: { type: string; text?: string }[] }
).content?.find((c) => c.type === "text")?.text;
expect(text).toContain("Completed 3 progress notifications");
await client.disconnect();
await server.stop();
});
it("should not timeout when resetTimeoutOnProgress is true and progress is sent (reset extends timeout)", async () => {
const { createSendProgressTool } =
await import("@modelcontextprotocol/inspector-test-server");
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
progress: true,
timeout: 350,
resetTimeoutOnProgress: true,
},
);
await client.connect();
const sendProgressTool = await getTool(client, "send_progress");
const result = await client.callTool(
sendProgressTool,
{ units: 4, delayMs: 200, total: 4, message: "Reset test" },
undefined,
{ progressToken: "reset-test" },
);
expect(result.success).toBe(true);
expect((result.result as { content?: unknown[] }).content).toBeDefined();
const text = (
result.result as { content?: { type: string; text?: string }[] }
).content?.find((c) => c.type === "text")?.text;
expect(text).toContain("Completed 4 progress notifications");
await client.disconnect();
await server.stop();
});
it("should timeout with RequestTimeout when resetTimeoutOnProgress is false and gap exceeds timeout", async () => {
const { createSendProgressTool } =
await import("@modelcontextprotocol/inspector-test-server");
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendProgressTool()],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
clientIdentity: { name: "test", version: "1.0.0" },
progress: true,
timeout: 150,
resetTimeoutOnProgress: false,
},
);
await client.connect();
const progressToken = 888;
const sendProgressToolTimeout = await getTool(client, "send_progress");
let err: unknown;
try {
await client.callTool(
sendProgressToolTimeout,
{ units: 4, delayMs: 200, total: 4, message: "Timeout test" },
undefined,
{ progressToken: progressToken.toString() },
);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(SdkError);
expect((err as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
await client.disconnect();
await server.stop();
});
});
describe("Logging", () => {
it("should set logging level when server supports it", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
initialLoggingLevel: "debug",
},
);
await client.connect();
// If server supports logging, the level should be set
// We can't directly verify this, but it shouldn't throw
const capabilities = client.getCapabilities();
if (capabilities?.logging) {
await client.setLoggingLevel("info");
}
});
it("does not stamp the modern per-request log level on a legacy connection (#1629)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
const messageLogState = new MessageLogState(client);
await client.connect();
// Setting the modern level is a no-op on a legacy server: the era gate in
// mergeMeta means the `logLevel` `_meta` key is never stamped there.
client.setModernLogLevel("debug");
expect(client.getModernLogLevel()).toBe("debug");
const tool = await getTool(client, "echo");
await client.callTool(tool, { message: "hi" });
const callToolReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/call",
);
expect(callToolReq).toBeDefined();
const params = (callToolReq!.message as { params?: { _meta?: unknown } })
.params;
const meta = (params?._meta as Record<string, unknown>) ?? {};
expect(meta[LOG_LEVEL_META_KEY]).toBeUndefined();
messageLogState.destroy();
});
it("should track stderr logs for stdio transport", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
pipeStderr: true,
},
);
const stderrLogState = new StderrLogState(client);
await client.connect();
const testMessage = `stderr-direct-${Date.now()}`;
const writeToStderrTool = await getTool(client, "write_to_stderr");
await client.callTool(writeToStderrTool, { message: testMessage });
// The child's stderr is piped out-of-band from the tool's JSON-RPC
// response, so the "data" chunk carrying `testMessage` can still be in
// flight when `callTool` resolves. Reading the log synchronously here
// races that chunk (the historical flake). Poll until the line lands
// instead of asserting on a single sample.
await vi.waitFor(
() => {
const logs = stderrLogState.getStderrLogs();
expect(Array.isArray(logs)).toBe(true);
const matching = logs.filter((l) => l.message.includes(testMessage));
expect(matching.length).toBeGreaterThan(0);
expect(matching[0]!.message).toContain(testMessage);
},
{ timeout: 5000, interval: 25 },
);
stderrLogState.destroy();
});
});
describe("Events", () => {
it("should emit statusChange events", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const statuses: ConnectionStatus[] = [];
client.addEventListener("statusChange", (event) => {
statuses.push(event.detail);
});
await client.connect();
await client.disconnect();
expect(statuses).toContain("connecting");
expect(statuses).toContain("connected");
expect(statuses).toContain("disconnected");
});
it("should emit connect event", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
let connectFired = false;
client.addEventListener("connect", () => {
connectFired = true;
});
await client.connect();
expect(connectFired).toBe(true);
});
it("should emit disconnect event", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
let disconnectFired = false;
client.addEventListener("disconnect", () => {
disconnectFired = true;
});
await client.connect();
await client.disconnect();
expect(disconnectFired).toBe(true);
});
it("emits an error event and sets status to error on a mid-session transport failure", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const errors: Error[] = [];
client.addEventListener("error", (event) => {
errors.push(event.detail);
});
await client.connect();
expect(client.getStatus()).toBe("connected");
// Simulate the transport dying mid-session (stdio crash / SSE drop /
// HTTP 5xx) by invoking the `onerror` the client attached to the base
// transport — the same callback the SDK fires on a real failure.
const baseTransport = (
client as unknown as {
baseTransport: { onerror?: (error: Error) => void };
}
).baseTransport;
const failure = new Error("stdio subprocess crashed");
baseTransport.onerror?.(failure);
expect(client.getStatus()).toBe("error");
expect(errors).toHaveLength(1);
expect(errors[0]).toBe(failure);
await client.disconnect();
});
it("still emits error when onclose lands before onerror on a mid-session crash", async () => {
// Many transports fire BOTH onclose and onerror on a real crash, in a
// transport-dependent order. When onclose lands first it flips status to
// "disconnected"; the guard must NOT swallow the trailing onerror's
// reason (its only surface). Regression lock for the #1489 re-review.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const errors: Error[] = [];
client.addEventListener("error", (event) => {
errors.push(event.detail);
});
await client.connect();
expect(client.getStatus()).toBe("connected");
const baseTransport = (
client as unknown as {
baseTransport: {
onclose?: () => void;
onerror?: (error: Error) => void;
};
}
).baseTransport;
// onclose first → status "disconnected"; then the trailing onerror.
baseTransport.onclose?.();
expect(client.getStatus()).toBe("disconnected");
const failure = new Error("stdio subprocess crashed");
baseTransport.onerror?.(failure);
expect(client.getStatus()).toBe("error");
expect(errors).toHaveLength(1);
expect(errors[0]).toBe(failure);
await client.disconnect();
});
it("holds status at error when a trailing onclose lands after onerror (#1490)", async () => {
// The reverse ordering of the test above: onerror lands first (status
// "error"), then the trailing onclose arrives. A bare
// `if (status !== "disconnected")` onclose guard would downgrade "error"
// back to "disconnected", so the persistent terminal status differed by
// transport ordering. onclose must now treat "error" as terminal and
// leave it untouched, while still emitting `disconnect` so teardown
// consumers fire identically in both orderings.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
const errors: Error[] = [];
let disconnects = 0;
client.addEventListener("error", (event) => {
errors.push(event.detail);
});
client.addEventListener("disconnect", () => {
disconnects++;
});
await client.connect();
expect(client.getStatus()).toBe("connected");
const baseTransport = (
client as unknown as {
baseTransport: {
onclose?: () => void;
onerror?: (error: Error) => void;
};
}
).baseTransport;
// onerror first → status "error".
const failure = new Error("stdio subprocess crashed");
baseTransport.onerror?.(failure);
expect(client.getStatus()).toBe("error");
// Trailing onclose must NOT downgrade "error" to "disconnected"...
baseTransport.onclose?.();
expect(client.getStatus()).toBe("error");
// ...but must still fire `disconnect` exactly once so session teardown
// (e.g. App resets the active server) happens regardless of ordering.
expect(disconnects).toBe(1);
expect(errors).toHaveLength(1);
expect(errors[0]).toBe(failure);
await client.disconnect();
});
it("emits disconnect on a mid-session crash regardless of onclose/onerror ordering (#1490)", async () => {
// Both real-world orderings must settle on the same observable outcome:
// final status "error" and exactly one `disconnect` event.
const run = async (order: "onclose-first" | "onerror-first") => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
let disconnects = 0;
c.addEventListener("disconnect", () => {
disconnects++;
});
await c.connect();
const bt = (
c as unknown as {
baseTransport: {
onclose?: () => void;
onerror?: (error: Error) => void;
};
}
).baseTransport;
const failure = new Error("crash");
if (order === "onclose-first") {
bt.onclose?.();
bt.onerror?.(failure);
} else {
bt.onerror?.(failure);
bt.onclose?.();
}
// Snapshot before the cleanup disconnect() below, which fires its own
// disconnect events as it tears the (still-live) real transport down.
const result = { status: c.getStatus(), disconnects };
await c.disconnect();
return result;
};
const closeFirst = await run("onclose-first");
const errorFirst = await run("onerror-first");
expect(closeFirst).toEqual({ status: "error", disconnects: 1 });
expect(errorFirst).toEqual({ status: "error", disconnects: 1 });
});
it("fires disconnect exactly once when explicitly disconnecting from a crashed (error) state (#1490)", async () => {
// After a crash holds status at "error", close() can fire the transport's
// onclose synchronously. onclose would emit `disconnect` (status held at
// "error"), and then disconnect()'s own guard — seeing "error" still
// != "disconnected" — would emit it a second time. The `disconnecting`
// ownership flag must collapse that to exactly one event regardless of
// whether the SDK closes synchronously.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
await client.connect();
const baseTransport = (
client as unknown as {
baseTransport: { onerror?: (error: Error) => void };
}
).baseTransport;
baseTransport.onerror?.(new Error("stdio subprocess crashed"));
expect(client.getStatus()).toBe("error");
// Listener attached AFTER the crash so it counts only the explicit
// disconnect()'s events.
let disconnects = 0;
client.addEventListener("disconnect", () => {
disconnects++;
});
await client.disconnect();
expect(disconnects).toBe(1);
expect(client.getStatus()).toBe("disconnected");
});
it("fires disconnect exactly once on a synchronous close() from error (deterministic sync-close pin, #1490)", async () => {
// The test above uses the real stdio transport, whose close() may invoke
// onclose asynchronously — so it asserts the invariant but does not
// deterministically exercise the sync-close branch that double-fired
// (onclose firing INSIDE close(), before disconnect()'s guard runs).
// Here we force that exact timing: override the SDK client's close() to
// invoke the transport's onclose synchronously. Without the
// `disconnecting` flag this asserts 2; with it, 1.
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
await client.connect();
const baseTransport = (
client as unknown as {
baseTransport: {
onclose?: () => void;
onerror?: (error: Error) => void;
};
}
).baseTransport;
// Put the client into the crashed "error" state.
baseTransport.onerror?.(new Error("stdio subprocess crashed"));
expect(client.getStatus()).toBe("error");
// Force close() to fire onclose synchronously (the sync-close branch),
// then delegate to the real close() so the subprocess is still torn down.
const sdkClient = (
client as unknown as { client: { close: () => Promise<void> } }
).client;
const realClose = sdkClient.close.bind(sdkClient);
sdkClient.close = async () => {
baseTransport.onclose?.();
await realClose();
};
// Listener attached AFTER the crash so it counts only disconnect()'s events.
let disconnects = 0;
client.addEventListener("disconnect", () => {
disconnects++;
});
await client.disconnect();
expect(disconnects).toBe(1);
expect(client.getStatus()).toBe("disconnected");
});
it("does not emit an error event when the handshake rejects connect()", async () => {
// A transport whose start() rejects makes connect() reject (handshake
// failure). The caller gets the reason via the rejected promise, so the
// client must NOT also dispatch the `error` event (which is reserved for
// non-awaited, mid-session failures). See #1323.
const failingFactory = () => ({
transport: {
start: () => Promise.reject(new Error("handshake refused")),
send: async () => {},
close: async () => {},
onclose: undefined,
onerror: undefined,
onmessage: undefined,
sessionId: undefined,
} as unknown as import("@modelcontextprotocol/client").Transport,
});
client = new InspectorClient(
{ type: "streamable-http", url: "http://localhost:1/never" },
{ environment: { transport: failingFactory } },
);
let errorFired = false;
client.addEventListener("error", () => {
errorFired = true;
});
await expect(client.connect()).rejects.toThrow(/handshake refused/);
expect(client.getStatus()).toBe("error");
expect(errorFired).toBe(false);
});
it("does not emit error or flip to error when onerror fires during the handshake", async () => {
// The transport listeners are attached before the handshake runs, so a
// transport that reports a connect-time error via `onerror` (some SDK
// transports do this in addition to rejecting connect()) must NOT be
// treated as a mid-session failure: that would double-report a handshake
// error the awaited connect() rejection already surfaces. See #1323.
let rejectStart: (error: Error) => void = () => {};
const startPromise = new Promise<void>((_resolve, reject) => {
rejectStart = reject;
});
const transport = {
start: () => startPromise,
send: async () => {},
close: async () => {},
onclose: undefined as undefined | (() => void),
onerror: undefined as undefined | ((error: Error) => void),
onmessage: undefined,
sessionId: undefined,
};
const factory = () => ({
transport:
transport as unknown as import("@modelcontextprotocol/client").Transport,
});
client = new InspectorClient(
{ type: "streamable-http", url: "http://localhost:1/never" },
{ environment: { transport: factory } },
);
let errorFired = false;
client.addEventListener("error", () => {
errorFired = true;
});
// Kick off connect() without awaiting — it parks on the hanging start().
// `status` is "connecting" and `onerror` is wired by this point.
const pending = client.connect().catch(() => {});
expect(client.getStatus()).toBe("connecting");
// A connect-time transport error arrives via onerror.
transport.onerror?.(new Error("socket error mid-handshake"));
// It must be ignored: still connecting, no error event dispatched.
expect(client.getStatus()).toBe("connecting");
expect(errorFired).toBe(false);
// Unblock the handshake so connect() settles (and tears down cleanly).
rejectStart(new Error("aborted"));
await pending;
// connect()'s catch transitions to "error" via the rejection path —
// still without dispatching the `error` event.
expect(client.getStatus()).toBe("error");
expect(errorFired).toBe(false);
});
});
describe("Sampling Requests", () => {
it("should handle sampling requests from server and respond", async () => {
// Create a test server with the collect_sample tool
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createCollectSampleTool()],
serverType: "streamable-http",
});
await server.start();
// Create client with sampling enabled
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
sample: true, // Enable sampling capability
},
);
await client.connect();
// Set up Promise to wait for sampling request event
const samplingRequestPromise = new Promise<SamplingCreateMessage>(
(resolve) => {
client!.addEventListener(
"newPendingSample",
(event) => {
resolve(event.detail);
},
{ once: true },
);
},
);
// Start the tool call (don't await yet - it will block until sampling is responded to)
const collectSampleTool = await getTool(client, "collect_sample");
const toolResultPromise = client.callTool(collectSampleTool, {
text: "Hello, world!",
});
// Wait for the sampling request to arrive via event
const pendingSample = await samplingRequestPromise;
// Verify we received a sampling request
expect(pendingSample.request.method).toBe("sampling/createMessage");
const messages = pendingSample.request.params.messages;
expect(messages.length).toBeGreaterThan(0);
const firstMessage = messages[0];
expect(firstMessage).toBeDefined();
if (
firstMessage &&
firstMessage.content &&
typeof firstMessage.content === "object" &&
"text" in firstMessage.content
) {
expect((firstMessage.content as { text: string }).text).toBe(
"Hello, world!",
);
}
// Respond to the sampling request
const samplingResponse: CreateMessageResult = {
model: "test-model",
role: "assistant",
stopReason: "endTurn",
content: {
type: "text",
text: "This is a test response",
},
};
await pendingSample.respond(samplingResponse);
// Now await the tool result (it should complete now that we've responded)
const toolResult = await toolResultPromise;
// Verify the tool result contains the sampling response
expect(toolResult).toBeDefined();
expect(toolResult.success).toBe(true);
expect(toolResult.result).toBeDefined();
expect(toolResult.result!.content).toBeDefined();
expect(Array.isArray(toolResult.result!.content)).toBe(true);
const toolContent = toolResult.result!.content as ContentBlock[];
expect(toolContent.length).toBeGreaterThan(0);
const toolMessage = toolContent[0];
expect(toolMessage).toBeDefined();
expect(toolMessage.type).toBe("text");
if (toolMessage.type === "text") {
expect(toolMessage.text).toContain("Sampling response:");
expect(toolMessage.text).toContain("test-model");
expect(toolMessage.text).toContain("This is a test response");
}
// Verify the pending sample was removed
const pendingSamples = client.getPendingSamples();
expect(pendingSamples.length).toBe(0);
});
});
describe("Server-Initiated Notifications", () => {
it("should receive server-initiated notifications via stdio transport", async () => {
// Note: stdio test server uses getDefaultServerConfig which now includes send_notification tool
// Create client with stdio transport
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Set up Promise to wait for notification
const notificationPromise = new Promise<MessageEntry>((resolve) => {
client!.addEventListener("message", (event) => {
const entry = event.detail;
if (entry.direction === "notification") {
resolve(entry);
}
});
});
// Call the send_notification tool
const sendNotifTool = await getTool(client, "send_notification");
await client.callTool(sendNotifTool, {
message: "Test notification from stdio",
level: "info",
});
// Wait for the notification
const notificationEntry = await notificationPromise;
// Validate the notification
expect(notificationEntry).toBeDefined();
expect(notificationEntry.direction).toBe("notification");
if ("method" in notificationEntry.message) {
expect(notificationEntry.message.method).toBe("notifications/message");
if ("params" in notificationEntry.message) {
const params = notificationEntry.message.params as Record<
string,
unknown
>;
expect((params.data as { message: string }).message).toBe(
"Test notification from stdio",
);
expect(params.level).toBe("info");
expect(params.logger).toBe("test-server");
}
}
});
it("should receive server-initiated notifications via SSE transport", async () => {
// Create a test server with the send_notification tool and logging enabled
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendNotificationTool()],
serverType: "sse",
logging: true, // Required for notifications/message
});
await server.start();
// Create client with SSE transport
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Set up Promise to wait for notification
const notificationPromise = new Promise<MessageEntry>((resolve) => {
client!.addEventListener("message", (event) => {
const entry = event.detail;
if (entry.direction === "notification") {
resolve(entry);
}
});
});
// Call the send_notification tool
const sendNotifToolSse = await getTool(client, "send_notification");
await client.callTool(sendNotifToolSse, {
message: "Test notification from SSE",
level: "warning",
});
// Wait for the notification
const notificationEntry = await notificationPromise;
// Validate the notification
expect(notificationEntry).toBeDefined();
expect(notificationEntry.direction).toBe("notification");
if ("method" in notificationEntry.message) {
expect(notificationEntry.message.method).toBe("notifications/message");
if ("params" in notificationEntry.message) {
const params = notificationEntry.message.params as Record<
string,
unknown
>;
expect((params.data as { message: string }).message).toBe(
"Test notification from SSE",
);
expect(params.level).toBe("warning");
expect(params.logger).toBe("test-server");
}
}
});
it("should receive server-initiated notifications via streamable-http transport", async () => {
// Create a test server with the send_notification tool and logging enabled
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createSendNotificationTool()],
serverType: "streamable-http",
logging: true, // Required for notifications/message
});
await server.start();
// Create client with streamable-http transport
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Set up Promise to wait for notification
const notificationPromise = new Promise<MessageEntry>((resolve) => {
client!.addEventListener("message", (event) => {
const entry = event.detail;
if (entry.direction === "notification") {
resolve(entry);
}
});
});
// Call the send_notification tool
const sendNotifToolHttp = await getTool(client, "send_notification");
await client.callTool(sendNotifToolHttp, {
message: "Test notification from streamable-http",
level: "error",
});
// Wait for the notification
const notificationEntry = await notificationPromise;
// Validate the notification
expect(notificationEntry).toBeDefined();
expect(notificationEntry.direction).toBe("notification");
if ("method" in notificationEntry.message) {
expect(notificationEntry.message.method).toBe("notifications/message");
if ("params" in notificationEntry.message) {
const params = notificationEntry.message.params as Record<
string,
unknown
>;
expect((params.data as { message: string }).message).toBe(
"Test notification from streamable-http",
);
expect(params.level).toBe("error");
expect(params.logger).toBe("test-server");
}
}
});
});
describe("Elicitation Requests", () => {
it("should handle form-based elicitation requests from server and respond", async () => {
// Create a test server with the collectElicitation tool
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createCollectFormElicitationTool()],
serverType: "streamable-http",
});
await server.start();
// Create client with elicitation enabled
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
elicit: true, // Enable elicitation capability
},
);
await client.connect();
// Set up Promise to wait for elicitation request event
const elicitationRequestPromise = new Promise<ElicitationCreateMessage>(
(resolve) => {
client!.addEventListener(
"newPendingElicitation",
(event) => {
resolve(event.detail);
},
{ once: true },
);
},
);
// Start the tool call (don't await yet - it will block until elicitation is responded to)
const collectElicitationTool = await getTool(
client,
"collect_elicitation",
);
const toolResultPromise = client.callTool(collectElicitationTool, {
message: "Please provide your name",
schema: {
type: "object",
properties: {
name: {
type: "string",
description: "Your name",
},
},
required: ["name"],
},
});
// Wait for the elicitation request to arrive via event
const pendingElicitation = await elicitationRequestPromise;
// Verify we received an elicitation request
expect(pendingElicitation.request.method).toBe("elicitation/create");
expect(pendingElicitation.request.params.message).toBe(
"Please provide your name",
);
if ("requestedSchema" in pendingElicitation.request.params) {
expect(pendingElicitation.request.params.requestedSchema).toBeDefined();
expect(pendingElicitation.request.params.requestedSchema.type).toBe(
"object",
);
}
// Respond to the elicitation request
const elicitationResponse: ElicitResult = {
action: "accept",
content: {
name: "Test User",
},
};
await pendingElicitation.respond(elicitationResponse);
// Now await the tool result (it should complete now that we've responded)
const toolResult = await toolResultPromise;
// Verify the tool result contains the elicitation response
expect(toolResult).toBeDefined();
expect(toolResult.success).toBe(true);
expect(toolResult.result).toBeDefined();
expect(toolResult.result!.content).toBeDefined();
expect(Array.isArray(toolResult.result!.content)).toBe(true);
const toolContent = toolResult.result!.content as ContentBlock[];
expect(toolContent.length).toBeGreaterThan(0);
const toolMessage = toolContent[0];
expect(toolMessage).toBeDefined();
expect(toolMessage.type).toBe("text");
if (toolMessage.type === "text") {
expect(toolMessage.text).toContain("Elicitation response:");
expect(toolMessage.text).toContain("accept");
expect(toolMessage.text).toContain("Test User");
}
// Verify the pending elicitation was removed
const pendingElicitations = client.getPendingElicitations();
expect(pendingElicitations.length).toBe(0);
});
it("should handle URL-based elicitation requests from server and respond", async () => {
// Create a test server with the collect_url_elicitation tool
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createCollectUrlElicitationTool()],
serverType: "streamable-http",
});
await server.start();
// Create client with elicitation enabled
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
elicit: { url: true }, // Enable elicitation capability
},
);
await client.connect();
// Set up Promise to wait for elicitation request event
const elicitationRequestPromise = new Promise<ElicitationCreateMessage>(
(resolve) => {
client!.addEventListener(
"newPendingElicitation",
(event) => {
resolve(event.detail);
},
{ once: true },
);
},
);
// Start the tool call (don't await yet - it will block until elicitation is responded to)
const collectUrlElicitationTool = await getTool(
client,
"collect_url_elicitation",
);
const toolResultPromise = client.callTool(collectUrlElicitationTool, {
message: "Please visit the URL to complete authentication",
url: "https://example.com/auth",
elicitationId: "test-url-elicitation-123",
});
// Wait for the elicitation request to arrive via event
const pendingElicitation = await elicitationRequestPromise;
// Verify we received a URL-based elicitation request
expect(pendingElicitation.request.method).toBe("elicitation/create");
expect(pendingElicitation.request.params.message).toBe(
"Please visit the URL to complete authentication",
);
expect(pendingElicitation.request.params.mode).toBe("url");
if (pendingElicitation.request.params.mode === "url") {
expect(pendingElicitation.request.params.url).toBe(
"https://example.com/auth",
);
expect(pendingElicitation.request.params.elicitationId).toBe(
"test-url-elicitation-123",
);
}
// Respond to the URL-based elicitation request
const elicitationResponse: ElicitResult = {
action: "accept",
content: {
// URL-based elicitation typically doesn't have form data, but we can include metadata
completed: true,
},
};
await pendingElicitation.respond(elicitationResponse);
// Now await the tool result (it should complete now that we've responded)
const toolResult = await toolResultPromise;
// Verify the tool result contains the elicitation response
expect(toolResult).toBeDefined();
expect(toolResult.success).toBe(true);
expect(toolResult.result).toBeDefined();
expect(toolResult.result!.content).toBeDefined();
expect(Array.isArray(toolResult.result!.content)).toBe(true);
const toolContent = toolResult.result!.content as ContentBlock[];
expect(toolContent.length).toBeGreaterThan(0);
const toolMessage = toolContent[0];
expect(toolMessage).toBeDefined();
expect(toolMessage.type).toBe("text");
if (toolMessage.type === "text") {
expect(toolMessage.text).toContain("URL elicitation response:");
expect(toolMessage.text).toContain("accept");
}
// Verify the pending elicitation was removed
const pendingElicitations = client.getPendingElicitations();
expect(pendingElicitations.length).toBe(0);
});
it("should handle url_elicitation_form: accept elicitation, receive completion notification, update pending state, and return tool result", async () => {
const submittedValue = "inspector-client-test-value-99";
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createUrlElicitationFormTool()],
serverType: "streamable-http",
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
elicit: { url: true },
},
);
await client.connect();
// Track pendingElicitationsChange events: expect [1] when elicitation arrives, [0] when complete notification received
const pendingElicitationsChangeEvents: ElicitationCreateMessage[][] = [];
client!.addEventListener(
"pendingElicitationsChange",
(event: TypedEvent<"pendingElicitationsChange">) => {
pendingElicitationsChangeEvents.push([...event.detail]);
},
);
const elicitationRequestPromise = new Promise<ElicitationCreateMessage>(
(resolve) => {
client!.addEventListener(
"newPendingElicitation",
(event) => resolve(event.detail),
{ once: true },
);
},
);
const urlElicitationFormTool = await getTool(
client,
"url_elicitation_form",
);
const toolResultPromise = client.callTool(urlElicitationFormTool, {});
const pendingElicitation = await elicitationRequestPromise;
expect(pendingElicitation.request.method).toBe("elicitation/create");
expect(pendingElicitation.request.params?.mode).toBe("url");
const url =
pendingElicitation.request.params?.mode === "url"
? pendingElicitation.request.params.url
: null;
const elicitationId =
pendingElicitation.request.params?.mode === "url"
? pendingElicitation.request.params.elicitationId
: null;
expect(url).toBeTruthy();
expect(elicitationId).toBeTruthy();
expect(client.getPendingElicitations()).toHaveLength(1);
// Respond with accept (unblocks server); then submit form to trigger completion notification
await pendingElicitation.respond({ action: "accept" });
const formData = new URLSearchParams({
value: submittedValue,
elicitation: elicitationId!,
});
await fetch(url!, {
method: "POST",
body: formData,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const toolResult = await toolResultPromise;
expect(toolResult).toBeDefined();
expect(toolResult.success).toBe(true);
expect(toolResult.result?.content).toBeDefined();
const content = toolResult.result!.content as Array<{
type: string;
text?: string;
}>;
const textBlock = content.find((c) => c.type === "text");
expect(textBlock?.text).toContain("Collected value:");
expect(textBlock?.text).toContain(submittedValue);
expect(client.getPendingElicitations()).toHaveLength(0);
// Verify event sequence: addPendingElicitation -> [1], then complete notification -> [0]
expect(pendingElicitationsChangeEvents.length).toBeGreaterThanOrEqual(2);
expect(pendingElicitationsChangeEvents[0]).toHaveLength(1);
const lastEvent =
pendingElicitationsChangeEvents[
pendingElicitationsChangeEvents.length - 1
];
expect(lastEvent).toHaveLength(0);
});
});
describe("Roots Support", () => {
it("should handle roots/list request from server and return roots", async () => {
// Create a test server with the list_roots tool
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createListRootsTool()],
serverType: "streamable-http",
});
await server.start();
// Create client with roots enabled
const initialRoots = [
{ uri: "file:///test1", name: "Test Root 1" },
{ uri: "file:///test2", name: "Test Root 2" },
];
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
roots: initialRoots, // Enable roots capability
},
);
await client.connect();
// Call the list_roots tool - it will call roots/list on the client
const listRootsTool = await getTool(client, "list_roots");
const toolResult = await client.callTool(listRootsTool, {});
// Verify the tool result contains the roots
expect(toolResult).toBeDefined();
expect(toolResult.success).toBe(true);
expect(toolResult.result).toBeDefined();
expect(toolResult.result!.content).toBeDefined();
expect(Array.isArray(toolResult.result!.content)).toBe(true);
const toolContent = toolResult.result!.content as ContentBlock[];
expect(toolContent.length).toBeGreaterThan(0);
const toolMessage = toolContent[0];
expect(toolMessage).toBeDefined();
expect(toolMessage.type).toBe("text");
if (toolMessage.type === "text") {
expect(toolMessage.text).toContain("Roots:");
expect(toolMessage.text).toContain("file:///test1");
expect(toolMessage.text).toContain("file:///test2");
}
// Verify getRoots() returns the roots
const roots = client.getRoots();
expect(roots).toEqual(initialRoots);
await client.disconnect();
await server.stop();
});
it("should send roots/list_changed notification when roots are updated", async () => {
// Create a test server - clients can send roots/list_changed notifications to any server
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
serverType: "streamable-http",
});
await server.start();
// Create client with roots enabled
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
roots: [], // Enable roots capability with empty array
},
);
await client.connect();
// Clear any recorded requests from connection
server.clearRecordings();
// Update roots
const newRoots = [
{ uri: "file:///new1", name: "New Root 1" },
{ uri: "file:///new2", name: "New Root 2" },
];
await client.setRoots(newRoots);
const rootsChangedNotification = await server.waitUntilRecorded(
(req) => req.method === "notifications/roots/list_changed",
{ timeout: 5000, interval: 10 },
);
expect(rootsChangedNotification.method).toBe(
"notifications/roots/list_changed",
);
// Verify getRoots() returns the new roots
const roots = client.getRoots();
expect(roots).toEqual(newRoots);
// Verify rootsChange event was dispatched
const rootsChangePromise = new Promise<CustomEvent>((resolve) => {
client!.addEventListener(
"rootsChange",
(event) => {
resolve(event);
},
{ once: true },
);
});
await client.setRoots([{ uri: "file:///updated", name: "Updated" }]);
const rootsChangeEvent = await rootsChangePromise;
expect(rootsChangeEvent.detail).toEqual([
{ uri: "file:///updated", name: "Updated" },
]);
// Verify another notification was sent
const updatedRequests = server.getRecordedRequests();
const secondNotification = updatedRequests.filter(
(req) => req.method === "notifications/roots/list_changed",
);
expect(secondNotification.length).toBeGreaterThanOrEqual(1);
await client!.disconnect();
await server.stop();
});
});
describe("Completions", () => {
it("should get completions for resource template variable", async () => {
// Create a test server with a resource template that has completion support
const completionCallback = (argName: string, value: string): string[] => {
if (argName === "path") {
const files = ["file1.txt", "file2.txt", "file3.txt"];
return files.filter((f) => f.startsWith(value));
}
return [];
};
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: [createFileResourceTemplate(completionCallback)],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Request completions for "file" variable with partial value "file1"
const result = await client.getCompletions(
{ type: "ref/resource", uri: "file:///{path}" },
"path",
"file1",
);
expect(result.values).toContain("file1.txt");
expect(result.values.length).toBeGreaterThan(0);
await client.disconnect();
await server.stop();
});
it("should get completions for prompt argument", async () => {
// Create a test server with a prompt that has completion support
const cityCompletions = (value: string): string[] => {
const cities = ["New York", "Los Angeles", "Chicago", "Houston"];
return cities.filter((c) =>
c.toLowerCase().startsWith(value.toLowerCase()),
);
};
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: [
createArgsPrompt({
city: cityCompletions,
}),
],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Request completions for "city" argument with partial value "New"
const result = await client.getCompletions(
{ type: "ref/prompt", name: "args_prompt" },
"city",
"New",
);
expect(result.values).toContain("New York");
expect(result.values.length).toBeGreaterThan(0);
await client.disconnect();
await server.stop();
});
it("should return empty array when server does not support completions", async () => {
// Create a test server without completion support
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: [createFileResourceTemplate()], // No completion callback
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Request completions - should return empty array (MethodNotFound handled gracefully)
const result = await client.getCompletions(
{ type: "ref/resource", uri: "file:///{path}" },
"path",
"file",
);
expect(result.values).toEqual([]);
await client.disconnect();
await server.stop();
});
it("should get completions with context (other arguments)", async () => {
// Create a test server with a prompt that uses context
const stateCompletions = (
value: string,
context?: Record<string, string>,
): string[] => {
const statesByCity: Record<string, string[]> = {
"New York": ["NY", "New York State"],
"Los Angeles": ["CA", "California"],
};
const city = context?.city;
if (city && statesByCity[city]) {
return statesByCity[city].filter((s) =>
s.toLowerCase().startsWith(value.toLowerCase()),
);
}
return ["NY", "CA", "TX", "FL"].filter((s) =>
s.toLowerCase().startsWith(value.toLowerCase()),
);
};
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
prompts: [
createArgsPrompt({
state: stateCompletions,
}),
],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
// Request completions for "state" with context (city="New York")
const result = await client.getCompletions(
{ type: "ref/prompt", name: "args_prompt" },
"state",
"N",
{ city: "New York" },
);
expect(result.values).toContain("NY");
expect(result.values).toContain("New York State");
await client.disconnect();
await server.stop();
});
it("should handle async completion callbacks", async () => {
// Create a test server with async completion callback
const asyncCompletionCallback = async (
_argName: string,
value: string,
): Promise<string[]> => {
// Simulate async I/O in completion callback; fixture behavior, not a test wait.
await new Promise((resolve) => setTimeout(resolve, 10));
const files = ["async1.txt", "async2.txt", "async3.txt"];
return files.filter((f) => f.startsWith(value));
};
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: [
createFileResourceTemplate(asyncCompletionCallback),
],
});
await server.start();
client = new InspectorClient(
{
type: "streamable-http",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
const result = await client.getCompletions(
{ type: "ref/resource", uri: "file:///{path}" },
"path",
"async1",
);
expect(result.values).toContain("async1.txt");
await client.disconnect();
await server.stop();
});
});
describe("Task Support", () => {
beforeEach(async () => {
// Create server with task support
const taskConfig = {
...getTaskServerConfig(),
serverType: "sse" as const,
};
server = createTestServerHttp(taskConfig);
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client.connect();
});
it("should detect task capabilities", () => {
const capabilities = client!.getTaskCapabilities();
expect(capabilities).toBeDefined();
expect(capabilities?.list).toBe(true);
expect(capabilities?.cancel).toBe(true);
});
it("should list tasks (empty initially)", async () => {
const result = await client!.listRequestorTasks();
expect(result).toHaveProperty("tasks");
expect(Array.isArray(result.tasks)).toBe(true);
});
it("should run tool as task (callTool with taskOptions returns task reference, poll getRequestorTask/getRequestorTaskResult yields result)", async () => {
// Same path as web App "Run as task": callTool with taskOptions -> task reference -> poll until completed
const optionalTaskTool = await getTool(client!, "optional_task");
const invocation = await client!.callTool(
optionalTaskTool,
{ message: "e2e-run-as-task" },
undefined,
undefined,
{ ttl: 5000 },
);
expect(invocation.success).toBe(true);
expect(invocation.result).toBeDefined();
expect(typeof invocation.result).toBe("object");
const rawResult = invocation.result as Record<string, unknown>;
expect(rawResult.task).toBeDefined();
const taskRef = rawResult.task as {
taskId: string;
status: string;
pollInterval?: number;
};
expect(taskRef.taskId).toBeDefined();
expect(typeof taskRef.taskId).toBe("string");
expect(taskRef.taskId.length).toBeGreaterThan(0);
expect(taskRef.status).toBeDefined();
expect(typeof taskRef.status).toBe("string");
const taskId = taskRef.taskId;
const pollIntervalMs = taskRef.pollInterval ?? 1000;
const timeoutMs = 12000;
const start = Date.now();
let task = await client!.getRequestorTask(taskId);
while (
task.status !== "completed" &&
task.status !== "failed" &&
task.status !== "cancelled"
) {
expect(Date.now() - start).toBeLessThan(timeoutMs);
await new Promise((r) => setTimeout(r, pollIntervalMs));
task = await client!.getRequestorTask(taskId);
}
expect(task.status).toBe("completed");
const result = await client!.getRequestorTaskResult(taskId);
expect(result).toBeDefined();
expect(result).toHaveProperty("content");
expect(Array.isArray(result.content)).toBe(true);
expect(result.content.length).toBe(1);
const firstContent = result.content[0];
expect(firstContent).toBeDefined();
expect(firstContent!.type).toBe("text");
expect(firstContent!).toHaveProperty("text");
const resultText = JSON.parse((firstContent as { text: string }).text);
expect(resultText.message).toBe("Task completed: e2e-run-as-task");
expect(resultText.taskId).toBe(taskId);
const listResult = await client!.listRequestorTasks();
const found = listResult.tasks.some((t) => t.taskId === taskId);
expect(found).toBe(true);
});
it("should call tool with task support using callToolStream", async () => {
const toolCallTaskUpdatedEvents: Array<{
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: unknown;
}> = [];
const toolCallResultEvents: Array<{
toolName: string;
params: Record<string, JsonValue>;
result: CallToolResult | null;
timestamp: Date;
success: boolean;
error?: string;
metadata?: Record<string, string>;
}> = [];
client!.addEventListener(
"toolCallTaskUpdated",
(event: TypedEvent<"toolCallTaskUpdated">) => {
toolCallTaskUpdatedEvents.push(event.detail);
},
);
client!.addEventListener(
"toolCallResultChange",
(event: TypedEvent<"toolCallResultChange">) => {
toolCallResultEvents.push(event.detail);
},
);
const simpleTaskTool = await getTool(client!, "simple_task");
const result = await client!.callToolStream(simpleTaskTool, {
message: "test task",
});
// Validate final result
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result).not.toBeNull();
expect(result.result).toHaveProperty("content");
// Validate result content structure
const toolResult = result.result!;
expect(toolResult.content).toBeDefined();
expect(Array.isArray(toolResult.content)).toBe(true);
expect(toolResult.content.length).toBe(1);
const firstContent = toolResult.content[0];
expect(firstContent).toBeDefined();
expect(firstContent).not.toBeUndefined();
expect(firstContent!.type).toBe("text");
// Validate result content value
if (firstContent && firstContent.type === "text") {
expect(firstContent.text).toBeDefined();
const resultText = JSON.parse(firstContent.text);
expect(resultText.message).toBe("Task completed: test task");
expect(resultText.taskId).toBeDefined();
expect(typeof resultText.taskId).toBe("string");
} else {
expect(firstContent?.type).toBe("text");
}
// Validate toolCallTaskUpdated events - first is task created, then status updates, last has result
expect(toolCallTaskUpdatedEvents.length).toBeGreaterThanOrEqual(1);
const createdEvent = toolCallTaskUpdatedEvents[0]!;
expect(createdEvent.taskId).toBeDefined();
expect(typeof createdEvent.taskId).toBe("string");
expect(createdEvent.task).toBeDefined();
expect(createdEvent.task.taskId).toBe(createdEvent.taskId);
expect(createdEvent.task.status).toBe("working");
expect(createdEvent.task).toHaveProperty("ttl");
expect(createdEvent.task).toHaveProperty("lastUpdatedAt");
const taskId = createdEvent.taskId;
// All events are for the same task and have valid structure
const statuses = toolCallTaskUpdatedEvents.map((event) => {
expect(event.taskId).toBe(taskId);
expect(event.task.taskId).toBe(taskId);
expect(event.task).toHaveProperty("status");
expect(event.task).toHaveProperty("ttl");
expect(event.task).toHaveProperty("lastUpdatedAt");
if (event.task.lastUpdatedAt) {
expect(typeof event.task.lastUpdatedAt).toBe("string");
expect(() => new Date(event.task.lastUpdatedAt!)).not.toThrow();
}
return event.task.status;
});
expect(statuses[statuses.length - 1]).toBe("completed");
statuses.forEach((status) => {
expect(["working", "completed"]).toContain(status);
});
if (toolCallTaskUpdatedEvents.length > 1) {
expect(statuses[0]).toBe("working");
expect(statuses[statuses.length - 1]).toBe("completed");
} else {
expect(statuses[0]).toBe("completed");
}
// Last event must have result (completed)
const completedEvent = toolCallTaskUpdatedEvents.find(
(e) => e.result !== undefined,
)!;
expect(completedEvent).toBeDefined();
expect(completedEvent.taskId).toBe(taskId);
expect(completedEvent.result).toBeDefined();
expect(completedEvent.result).toEqual(toolResult);
// Validate toolCallResultChange event
expect(toolCallResultEvents.length).toBe(1);
const toolCallEvent = toolCallResultEvents[0]!;
expect(toolCallEvent.toolName).toBe("simple_task");
expect(toolCallEvent.params).toEqual({ message: "test task" });
expect(toolCallEvent.success).toBe(true);
expect(toolCallEvent.result).toEqual(toolResult);
expect(toolCallEvent.timestamp).toBeInstanceOf(Date);
// Validate task in requestor tasks (from server list)
const { tasks: requestorTasks } = await client!.listRequestorTasks();
const cachedTask = requestorTasks.find((t) => t.taskId === taskId);
expect(cachedTask).toBeDefined();
expect(cachedTask!.taskId).toBe(taskId);
expect(cachedTask!.status).toBe("completed");
expect(cachedTask!).toHaveProperty("ttl");
expect(cachedTask!).toHaveProperty("lastUpdatedAt");
// Validate consistency: taskId from all sources matches
expect(createdEvent.taskId).toBe(taskId);
expect(completedEvent.taskId).toBe(taskId);
expect(cachedTask!.taskId).toBe(taskId);
if (firstContent && firstContent.type === "text") {
const resultText = JSON.parse(firstContent.text);
expect(resultText.taskId).toBe(taskId);
}
});
it("should accept taskOptions (ttl) in callToolStream", async () => {
const simpleTaskTtlTool = await getTool(client!, "simple_task");
const result = await client!.callToolStream(
simpleTaskTtlTool,
{ message: "ttl-test" },
undefined,
undefined,
{ ttl: 99999 },
);
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
const { tasks } = await client!.listRequestorTasks();
const task = tasks.find((t) => t.taskId && t.status === "completed");
expect(task).toBeDefined();
expect(task).toHaveProperty("ttl");
});
it("emits requestorTaskProgress tagged with the owning taskId during a task-augmented call", async () => {
// Core-side progress→task correlation (#1422): the wrapped onprogress in
// callToolStream tags each tick with the taskId it owns, so App can map
// progress to the right TaskCard. The progress_task tool emits 5 ticks.
const progressEvents: Array<{ taskId: string; progress: Progress }> = [];
client!.addEventListener(
"requestorTaskProgress",
(event: TypedEvent<"requestorTaskProgress">) => {
progressEvents.push(event.detail);
},
);
// Capture the created taskId from the toolCall event stream for comparison.
let createdTaskId: string | undefined;
client!.addEventListener(
"toolCallTaskUpdated",
(event: TypedEvent<"toolCallTaskUpdated">) => {
createdTaskId ??= event.detail.taskId;
},
);
const progressTool = await getTool(client!, "progress_task");
const result = await client!.callToolStream(progressTool, {
message: "with progress",
});
expect(result.success).toBe(true);
expect(progressEvents.length).toBeGreaterThan(0);
// Every tick is tagged with the same (and the correct) task id.
const taskIds = new Set(progressEvents.map((e) => e.taskId));
expect(taskIds.size).toBe(1);
const [taggedTaskId] = [...taskIds];
expect(typeof taggedTaskId).toBe("string");
expect(taggedTaskId!.length).toBeGreaterThan(0);
expect(taggedTaskId).toBe(createdTaskId);
// The payload carries the server's progress units.
expect(progressEvents.some((e) => e.progress.total === 5)).toBe(true);
});
it("does not emit requestorTaskProgress when progress is globally disabled", async () => {
// The callToolStream onprogress wrapper is gated on `this.progress`, so a
// client with progress disabled neither requests a progress token nor
// emits requestorTaskProgress — task calls respect the same toggle as
// every other call path. The task still completes.
const noProgressClient = new InspectorClient(
{ type: "sse", url: server!.url },
{ environment: { transport: createTransportNode }, progress: false },
);
await noProgressClient.connect();
try {
const progressEvents: Array<{ taskId: string; progress: Progress }> =
[];
noProgressClient.addEventListener(
"requestorTaskProgress",
(event: TypedEvent<"requestorTaskProgress">) => {
progressEvents.push(event.detail);
},
);
const progressTool = await getTool(noProgressClient, "progress_task");
const result = await noProgressClient.callToolStream(progressTool, {
message: "no progress",
});
expect(result.success).toBe(true);
expect(progressEvents).toHaveLength(0);
} finally {
await noProgressClient.disconnect();
}
});
it("should get task by taskId", async () => {
// First create a task
const simpleTaskByIdTool = await getTool(client!, "simple_task");
const result = await client!.callToolStream(simpleTaskByIdTool, {
message: "test",
});
expect(result.success).toBe(true);
// Get the taskId from server task list
const { tasks: activeTasks } = await client!.listRequestorTasks();
expect(activeTasks.length).toBeGreaterThan(0);
const activeTask = activeTasks[0];
expect(activeTask).toBeDefined();
const taskId = activeTask!.taskId;
// Get the task
const task = await client!.getRequestorTask(taskId);
expect(task).toBeDefined();
expect(task.taskId).toBe(taskId);
expect(task.status).toBe("completed");
});
it("should get task result", async () => {
// First create a task
const simpleTaskResultTool = await getTool(client!, "simple_task");
const result = await client!.callToolStream(simpleTaskResultTool, {
message: "test result",
});
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result).not.toBeNull();
// Get the taskId from server task list
const { tasks: requestorTasks } = await client!.listRequestorTasks();
expect(requestorTasks.length).toBeGreaterThan(0);
const task = requestorTasks.find((t) => t.status === "completed");
expect(task).toBeDefined();
const taskId = task!.taskId;
// Get the task result
const taskResult = await client!.getRequestorTaskResult(taskId);
// Validate result structure
expect(taskResult).toBeDefined();
expect(taskResult).toHaveProperty("content");
expect(Array.isArray(taskResult.content)).toBe(true);
expect(taskResult.content.length).toBe(1);
// Validate content structure
const firstContent = taskResult.content[0];
expect(firstContent).toBeDefined();
expect(firstContent).not.toBeUndefined();
expect(firstContent!.type).toBe("text");
// Validate content value
if (firstContent && firstContent.type === "text") {
expect(firstContent.text).toBeDefined();
const resultText = JSON.parse(firstContent.text);
expect(resultText.message).toBe("Task completed: test result");
expect(resultText.taskId).toBe(taskId);
} else {
expect(firstContent?.type).toBe("text");
}
// Validate that getTaskResult returns the same result as callToolStream
expect(taskResult).toEqual(result.result);
});
it("should throw error when calling callTool on task-required tool", async () => {
const simpleTaskRequiredTool = await getTool(client!, "simple_task");
await expect(
client!.callTool(simpleTaskRequiredTool, { message: "test" }),
).rejects.toThrow("requires task support");
});
it("should clear tasks on disconnect", async () => {
// Create a task
const simpleTaskDisconnectTool = await getTool(client!, "simple_task");
await client!.callToolStream(simpleTaskDisconnectTool, {
message: "test",
});
const listBefore = await client!.listRequestorTasks();
expect(listBefore.tasks.length).toBeGreaterThan(0);
// Disconnect
await client!.disconnect();
// After disconnect we cannot list tasks (not connected); test that client is disconnected
expect(client!.getStatus()).toBe("disconnected");
});
it("should call tool with taskSupport: forbidden (immediate result, no task)", async () => {
// forbiddenTask should return immediately without creating a task
const forbiddenTaskTool = await getTool(client!, "forbidden_task");
const result = await client!.callToolStream(forbiddenTaskTool, {
message: "test",
});
expect(result.success).toBe(true);
expect(result.result).toHaveProperty("content");
// No task should be created (forbidden_task returns immediately)
const { tasks } = await client!.listRequestorTasks();
expect(tasks.length).toBe(0);
});
it("should call tool with taskSupport: optional (may or may not create task)", async () => {
// optionalTask may create a task or return immediately
const optionalTaskStreamTool = await getTool(client!, "optional_task");
const result = await client!.callToolStream(optionalTaskStreamTool, {
message: "test",
});
expect(result.success).toBe(true);
expect(result.result).toHaveProperty("content");
// Task may or may not be created - both are valid
});
it("should handle task failure and dispatch taskFailed event", async () => {
await client!.disconnect();
await server?.stop();
// Create a task tool that will fail after a short delay
const failingTask = createTaskTool({
name: "failingTask",
delayMs: 100,
failAfterDelay: 50, // Fail after 50ms
});
const taskConfig = getTaskServerConfig();
const failConfig = {
...taskConfig,
serverType: "sse" as const,
tools: [failingTask, ...(taskConfig.tools || [])],
};
server = createTestServerHttp(failConfig);
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client!.connect();
const failedPromise = expect(
(async () => {
const failingTaskTool = await getTool(client!, "failingTask");
return client!.callToolStream(failingTaskTool, { message: "test" });
})(),
).rejects.toThrow();
const taskFailedDetail = await new Promise<{
taskId: string;
task: TaskWithOptionalCreatedAt;
error?: unknown;
}>((resolve, reject) => {
const timeout = setTimeout(
() =>
reject(
new Error("Timeout waiting for toolCallTaskUpdated with error"),
),
2000,
);
const handler = (
e: Event & {
detail: {
taskId: string;
task: TaskWithOptionalCreatedAt;
error?: unknown;
};
},
) => {
if (e.detail.error !== undefined) {
clearTimeout(timeout);
client!.removeEventListener("toolCallTaskUpdated", handler);
resolve(e.detail);
}
};
client!.addEventListener("toolCallTaskUpdated", handler);
});
expect(taskFailedDetail.taskId).toBeDefined();
expect(taskFailedDetail.error).toBeDefined();
await failedPromise;
});
it("should cancel a running task", async () => {
await client!.disconnect();
await server?.stop();
// Create a longer-running task tool
const longRunningTask = createTaskTool({
name: "longRunningTask",
delayMs: 2000, // 2 seconds
});
const taskConfig = getTaskServerConfig();
const cancelConfig = {
...taskConfig,
serverType: "sse" as const,
tools: [longRunningTask, ...(taskConfig.tools || [])],
};
server = createTestServerHttp(cancelConfig);
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
},
);
await client!.connect();
const longRunningTaskTool = await getTool(client!, "longRunningTask");
const taskPromise = client!.callToolStream(longRunningTaskTool, {
message: "test",
});
const taskCreatedDetail = await waitForEvent<{
taskId: string;
task: TaskWithOptionalCreatedAt;
}>(client, "toolCallTaskUpdated", { timeout: 3000 });
const taskId = taskCreatedDetail.taskId;
expect(taskId).toBeDefined();
// Collect the optimistic task updates the stream emits. The cancellation
// ends the stream with a generic error; the terminal update must report
// "cancelled" (not "failed") so the UI lands on the true state without
// waiting for a refresh (#1455).
const taskUpdates: TaskWithOptionalCreatedAt[] = [];
const onRequestorTaskUpdated = (
e: CustomEvent<{ taskId: string; task: TaskWithOptionalCreatedAt }>,
) => {
taskUpdates.push(e.detail.task);
};
client!.addEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated as EventListener,
);
const cancelledPromise = waitForEvent<{ taskId: string }>(
client,
"taskCancelled",
{ timeout: 3000 },
);
await client!.cancelRequestorTask(taskId);
const [cancelledResult, taskResult] = await Promise.allSettled([
cancelledPromise,
taskPromise,
]);
client!.removeEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated as EventListener,
);
expect(cancelledResult.status).toBe("fulfilled");
const cancelledDetail = (
cancelledResult as PromiseFulfilledResult<{ taskId: string }>
).value;
expect(cancelledDetail.taskId).toBe(taskId);
expect(taskResult.status).toBe("rejected");
// The terminal optimistic update is "cancelled", never "failed".
const terminalUpdate = taskUpdates.find((t) =>
["completed", "failed", "cancelled"].includes(t.status),
);
expect(terminalUpdate?.status).toBe("cancelled");
const task = await client!.getRequestorTask(taskId);
expect(task.status).toBe("cancelled");
});
it("should handle elicitation with task (input_required flow)", async () => {
await client!.disconnect();
await server?.stop();
const elicitationConfig = {
...getTaskServerConfig(),
serverType: "sse" as const,
tools: [
createElicitationTaskTool("taskWithElicitation"),
...(getTaskServerConfig().tools || []),
],
};
server = createTestServerHttp(elicitationConfig);
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
elicit: true,
},
);
await client.connect();
const elicitationPromise = waitForEvent<ElicitationCreateMessage>(
client,
"newPendingElicitation",
{ timeout: 2000 },
);
const taskWithElicitationTool = await getTool(
client,
"taskWithElicitation",
);
const taskPromise = client.callToolStream(taskWithElicitationTool, {
message: "test",
});
const elicitation = await elicitationPromise;
// Verify elicitation was received
expect(elicitation).toBeDefined();
// Verify task status is input_required (if taskId was extracted)
if (elicitation.taskId) {
const { tasks: activeTasks } = await client.listRequestorTasks();
const task = activeTasks.find((t) => t.taskId === elicitation.taskId);
if (task) {
expect(task.status).toBe("input_required");
}
}
// Respond to elicitation with correct format
await elicitation.respond({
action: "accept",
content: {
input: "test input",
},
});
// Wait for task to complete
const result = await taskPromise;
expect(result.success).toBe(true);
});
it("should handle sampling with task (input_required flow)", async () => {
await client!.disconnect();
await server?.stop();
const samplingConfig = {
...getTaskServerConfig(),
serverType: "sse" as const,
tools: [
createSamplingTaskTool("taskWithSampling"),
...(getTaskServerConfig().tools || []),
],
};
server = createTestServerHttp(samplingConfig);
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
sample: true,
},
);
await client!.connect();
const samplingPromise = waitForEvent<SamplingCreateMessage>(
client,
"newPendingSample",
{ timeout: 3000 },
);
const taskCreatedPromise = waitForEvent<{ taskId: string; task: Task }>(
client,
"toolCallTaskUpdated",
{ timeout: 3000 },
);
const taskWithSamplingTool = await getTool(client!, "taskWithSampling");
const taskPromise = client!.callToolStream(taskWithSamplingTool, {
message: "test",
});
const sample = await samplingPromise;
expect(sample).toBeDefined();
const taskCreatedDetail = await taskCreatedPromise;
const task = await client!.getRequestorTask(taskCreatedDetail.taskId);
expect(task).toBeDefined();
expect(task!.status).toBe("input_required");
// Respond to sampling with correct format
await sample.respond({
model: "test-model",
role: "assistant",
stopReason: "endTurn",
content: {
type: "text",
text: "Sampling response",
},
});
// Wait for task to complete
const result = await taskPromise;
expect(result.success).toBe(true);
});
it("should handle progress notifications linked to tasks", async () => {
await client!.disconnect();
await server?.stop();
// createProgressTaskTool defaults to 5 progress units with 2000ms delay
// Progress notifications are sent at delayMs / progressUnits intervals (400ms each)
const progressConfig = {
...getTaskServerConfig(),
serverType: "sse" as const,
tools: [
createProgressTaskTool("taskWithProgress", 2000, 5),
...(getTaskServerConfig().tools || []),
],
};
server = createTestServerHttp(progressConfig);
await server.start();
client = new InspectorClient(
{
type: "sse",
url: server.url,
},
{
environment: { transport: createTransportNode },
progress: true,
},
);
await client!.connect();
const progressToken = Math.random().toString();
const taskCreatedPromise = waitForEvent<{ taskId: string; task: Task }>(
client,
"toolCallTaskUpdated",
{ timeout: 5000 },
);
const progressPromise = waitForProgressCount(client!, 5, {
timeout: 5000,
});
const taskWithProgressTool = await getTool(client!, "taskWithProgress");
const resultPromise = client!.callToolStream(
taskWithProgressTool,
{ message: "test" },
undefined,
{ progressToken },
);
const taskCreatedDetail = await taskCreatedPromise;
const taskId = taskCreatedDetail.taskId;
expect(taskId).toBeDefined();
const taskCompletedDetail = await new Promise<{
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: unknown;
}>((resolve, reject) => {
const timeout = setTimeout(
() =>
reject(
new Error("Timeout waiting for toolCallTaskUpdated with result"),
),
5000,
);
const handler = (
e: Event & {
detail: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: unknown;
};
},
) => {
if (e.detail.result !== undefined) {
clearTimeout(timeout);
client!.removeEventListener("toolCallTaskUpdated", handler);
resolve(e.detail);
}
};
client!.addEventListener("toolCallTaskUpdated", handler);
});
const progressEvents = await progressPromise;
const result = await resultPromise;
// Verify task completed successfully
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result).not.toBeNull();
expect(result.result).toHaveProperty("content");
// Validate the actual tool call response content
const toolResult = result.result!;
expect(toolResult.content).toBeDefined();
expect(Array.isArray(toolResult.content)).toBe(true);
expect(toolResult.content.length).toBe(1);
const firstContent = toolResult.content[0];
expect(firstContent).toBeDefined();
expect(firstContent).not.toBeUndefined();
expect(firstContent!.type).toBe("text");
// Assert it's a text content block (for TypeScript narrowing)
expect(firstContent!.type === "text").toBe(true);
// TypeScript type narrowing - we've already asserted it's text
if (firstContent && firstContent.type === "text") {
expect(firstContent.text).toBeDefined();
// Parse and validate the JSON text content
const resultText = JSON.parse(firstContent.text);
expect(resultText.message).toBe("Task completed: test");
expect(resultText.taskId).toBe(taskId);
} else {
// This should never happen due to the assertion above, but TypeScript needs it
expect(firstContent?.type).toBe("text");
}
expect(taskCompletedDetail.taskId).toBe(taskId);
expect(taskCompletedDetail.result).toBeDefined();
expect(taskCompletedDetail.result).toEqual(toolResult);
expect(progressEvents.length).toBe(5);
progressEvents.forEach((evt: unknown, index: number) => {
const event = evt as {
progressToken: string;
progress: number;
total: number;
message: string;
_meta?: Record<string, unknown>;
};
expect(event.progressToken).toBe(progressToken);
expect(event.progress).toBe(index + 1);
expect(event.total).toBe(5);
expect(event.message).toBe(`Processing... ${index + 1}/5`);
expect(event._meta).toBeDefined();
expect(event._meta?.[RELATED_TASK_META_KEY]).toBeDefined();
const relatedTask = event._meta?.[RELATED_TASK_META_KEY] as {
taskId: string;
};
expect(relatedTask.taskId).toBe(taskId);
});
// Verify task is in completed state (from server list)
const { tasks: activeTasks } = await client!.listRequestorTasks();
const completedTask = activeTasks.find((t) => t.taskId === taskId);
expect(completedTask).toBeDefined();
expect(completedTask!.status).toBe("completed");
});
it("should handle listTasks pagination", async () => {
const simpleTaskPaginationTool = await getTool(client!, "simple_task");
await client!.callToolStream(simpleTaskPaginationTool, {
message: "task1",
});
await client!.callToolStream(simpleTaskPaginationTool, {
message: "task2",
});
await client!.callToolStream(simpleTaskPaginationTool, {
message: "task3",
});
const result = await client!.listRequestorTasks();
expect(result.tasks.length).toBeGreaterThan(0);
// If there's a nextCursor, test pagination
if (result.nextCursor) {
const nextPage = await client!.listRequestorTasks(result.nextCursor);
expect(nextPage.tasks).toBeDefined();
expect(Array.isArray(nextPage.tasks)).toBe(true);
}
});
});
describe("Receiver tasks (e2e)", () => {
it("server sends createMessage with params.task, client returns task, test responds, server gets payload via tasks/get and tasks/result", async () => {
if (client) await client.disconnect();
client = null;
await server?.stop();
const config = {
...getTaskServerConfig(),
serverType: "sse" as const,
tools: [
createTaskTool({
name: "receiverE2ESampling",
samplingText: "Reply for e2e",
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 receiverE2ESamplingTool = await getTool(
client,
"receiverE2ESampling",
);
const taskPromise = client.callToolStream(receiverE2ESamplingTool, {
message: "e2e",
});
const sample = await samplingPromise;
expect(sample).toBeDefined();
await sample.respond({
model: "e2e-model",
role: "assistant",
stopReason: "endTurn",
content: { type: "text", text: "E2E receiver response" },
});
const result = await taskPromise;
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result).not.toBeNull();
expect(result.result!.content).toBeDefined();
const content = result.result!.content!;
const textBlock = Array.isArray(content) ? content[0] : content;
expect(textBlock).toBeDefined();
expect(
textBlock &&
typeof textBlock === "object" &&
"type" in textBlock &&
textBlock.type === "text",
).toBe(true);
if (textBlock && typeof textBlock === "object" && "text" in textBlock) {
expect((textBlock as { text: string }).text).toBe(
"E2E receiver response",
);
}
});
it("server sends elicit with params.task, client returns task, test responds, server gets payload via tasks/get and tasks/result", async () => {
if (client) await client.disconnect();
client = null;
await server?.stop();
const config = {
...getTaskServerConfig(),
serverType: "sse" as const,
tools: [
createTaskTool({
name: "receiverE2EElicit",
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 receiverE2EElicitTool = await getTool(client, "receiverE2EElicit");
const taskPromise = client.callToolStream(receiverE2EElicitTool, {
message: "e2e",
});
const elicitation = await elicitationPromise;
expect(elicitation).toBeDefined();
await elicitation.respond({
action: "accept",
content: { input: "E2E elicitation input" },
});
const result = await taskPromise;
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result).not.toBeNull();
expect(result.result!.content).toBeDefined();
// Elicit payload from tasks/result is JSON in a text block
const content = result.result!.content!;
const textBlock = Array.isArray(content) ? content[0] : content;
expect(
textBlock && typeof textBlock === "object" && "text" in textBlock,
).toBe(true);
const parsed = JSON.parse((textBlock as { text: string }).text) as Record<
string,
unknown
>;
expect(parsed.input).toBe("E2E elicitation input");
});
});
describe("Coverage backfill (#1310)", () => {
describe("guard methods without server / OAuth", () => {
it("returns no-op defaults from OAuth getters when oauthManager is unset", async () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
await expect(c.getOAuthTokens()).resolves.toBeUndefined();
await expect(c.isOAuthAuthorized()).resolves.toBe(false);
expect(c.getOAuthFlowStep()).toBeUndefined();
expect(c.getOAuthFlowState()).toBeUndefined();
await expect(c.getOAuthState()).resolves.toBeUndefined();
// clearOAuthTokens is a no-op when there is no manager
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
});
it("setOAuthConfig throws when oauthManager is unset", () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
expect(() => c.setOAuthConfig({ clientId: "x" })).toThrow(
/OAuth config must be set at creation/,
);
});
it("authenticate throws via ensureOAuthManager when oauthManager is unset", async () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
await expect(c.authenticate()).rejects.toThrow(/OAuth not configured/);
});
it("simple session/roots/subscription accessors return empty defaults before connect", () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
expect(c.getSessionId()).toBeUndefined();
// saveSession is a no-op when there is no sessionId
expect(() => c.saveSession()).not.toThrow();
c.setSessionId("session-123");
expect(c.getSessionId()).toBe("session-123");
expect(c.getSubscribedResources()).toEqual([]);
expect(c.isSubscribedToResource("file:///nope")).toBe(false);
// No server capabilities loaded yet -> subscriptions unsupported
expect(c.supportsResourceSubscriptions()).toBe(false);
// No instructions loaded before connect
expect(c.getInstructions()).toBeUndefined();
// Roots default to [] when undefined
expect(c.getRoots()).toEqual([]);
});
it("subscribe / unsubscribe error when server doesn't advertise subscribe capability", async () => {
// Stdio test server doesn't advertise resources.subscribe by default
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(2),
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
await expect(
client.subscribeToResource("test://resource_1"),
).rejects.toThrow(/does not support resource subscriptions/);
// unsubscribe path doesn't guard on capability; it calls the SDK which
// will reject because the server has no unsubscribe handler.
await expect(
client.unsubscribeFromResource("test://resource_1"),
).rejects.toThrow(
/Failed to unsubscribe to resource|Failed to unsubscribe from resource|Method not found/,
);
});
});
describe("subscribe / unsubscribe happy path", () => {
it("subscribes, reports state, then unsubscribes, when server advertises subscribe", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resources: createNumberedResources(2),
subscriptions: true,
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
expect(client.supportsResourceSubscriptions()).toBe(true);
await client.subscribeToResource("test://resource_1");
expect(client.getSubscribedResources()).toContain("test://resource_1");
expect(client.isSubscribedToResource("test://resource_1")).toBe(true);
await client.unsubscribeFromResource("test://resource_1");
expect(client.getSubscribedResources()).not.toContain(
"test://resource_1",
);
expect(client.isSubscribedToResource("test://resource_1")).toBe(false);
});
});
describe("getPrompt + readResourceFromTemplate", () => {
beforeEach(async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
resourceTemplates: [createFileResourceTemplate()],
prompts: [createArgsPrompt()],
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
});
it("getPrompt fetches and returns an invocation, dispatching no extra error", async () => {
const invocation = await client!.getPrompt("args_prompt", {
city: "Hartford",
state: "Connecticut",
});
expect(invocation.name).toBe("args_prompt");
expect(invocation.params).toEqual({
city: "Hartford",
state: "Connecticut",
});
expect(invocation.result).toBeDefined();
expect(invocation.timestamp).toBeInstanceOf(Date);
});
it("readResourceFromTemplate expands and reads the resource", async () => {
const invocation = await client!.readResourceFromTemplate(
"file:///{path}",
{ path: "report.txt" },
);
expect(invocation.uriTemplate).toBe("file:///{path}");
expect(invocation.expandedUri).toBe("file:///report.txt");
expect(invocation.params).toEqual({ path: "report.txt" });
expect(invocation.result).toBeDefined();
});
it("readResourceFromTemplate throws when expansion fails", async () => {
// Pass a syntactically invalid template; UriTemplate ctor throws
await expect(
client!.readResourceFromTemplate("file:///{unclosed", {
unclosed: "x",
}),
).rejects.toThrow(/Failed to expand URI template/);
});
});
describe("capability detection after connect", () => {
it("round-trips listChanged + subscribe flags via getCapabilities()", async () => {
// The handler-registration arrows in InspectorClient fire during
// connect only when the matching server capability is advertised.
// Exercise all four conditional branches in one connect by enabling
// tools/resources/prompts listChanged + resource subscriptions.
// The resources/prompts arrays are required for the test server to
// actually emit those capability blocks (an empty list omits the
// capability rather than advertising an empty one).
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
resources: createNumberedResources(1),
prompts: [createArgsPrompt()],
listChanged: { tools: true, resources: true, prompts: true },
subscriptions: true,
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
const caps = client.getCapabilities();
expect(caps?.tools?.listChanged).toBe(true);
expect(caps?.resources?.listChanged).toBe(true);
expect(caps?.prompts?.listChanged).toBe(true);
expect(caps?.resources?.subscribe).toBe(true);
});
});
describe("setLoggingLevel guards", () => {
it("throws when the server does not advertise logging support", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
await expect(client.setLoggingLevel("info")).rejects.toThrow(
/Server does not support logging/,
);
});
});
describe("getAppRendererClient", () => {
it("returns null before connect, and a cached proxy after connect", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
const c = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
// Disconnected => null
expect(c.getAppRendererClient()).toBeNull();
client = c;
await c.connect();
const proxy1 = c.getAppRendererClient();
expect(proxy1).not.toBeNull();
// Second call returns the cached proxy
expect(c.getAppRendererClient()).toBe(proxy1);
expect(
typeof (proxy1 as unknown as { setNotificationHandler?: unknown })
.setNotificationHandler,
).toBe("function");
});
it("translates the ext-apps v1 schema-first setNotificationHandler call to v2's method-string form", async () => {
// Regression: `@modelcontextprotocol/ext-apps` (SDK v1 peer) subscribes
// with `setNotificationHandler(NotificationSchema, handler)`. On SDK v2
// that throws "'[object Object]' is not a spec notification method",
// breaking App rendering at connect. The proxy must translate the
// schema (whose `.shape.method.value` is the method literal) to the
// method string so the handler still fires on the real notification.
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createAddToolTool()],
listChanged: { tools: true },
});
await server.start();
const c = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
client = c;
await c.connect();
const proxy = c.getAppRendererClient() as unknown as {
setNotificationHandler: (
schema: unknown,
handler: () => void,
) => void;
};
// A v1-style Zod notification schema, as ext-apps passes it (NOT a
// string): its `.shape.method.value` carries the method literal.
const v1StyleSchema = {
shape: { method: { value: "notifications/tools/list_changed" } },
};
let handlerFired = false;
expect(() =>
proxy.setNotificationHandler(v1StyleSchema, () => {
handlerFired = true;
}),
).not.toThrow();
// The registration must land under the extracted method string: trigger
// a real tools/list_changed and confirm the schema-first handler runs.
const addToolTool = await getTool(c, "add_tool");
await c.callTool(addToolTool, {
name: "added_via_schema_first",
description: "added at runtime",
});
await vi.waitFor(() => expect(handlerFired).toBe(true), {
timeout: 5000,
});
// A native string-first call (ours) passes through unchanged.
expect(() =>
proxy.setNotificationHandler(
"notifications/prompts/list_changed",
() => {},
),
).not.toThrow();
// An unrecognized first arg (no `.shape.method.value`) can't be
// translated and falls through to the SDK, which rejects it clearly —
// we don't silently swallow a genuinely-malformed registration.
expect(() =>
proxy.setNotificationHandler({} as unknown, () => {}),
).toThrow(/not a spec notification method/);
});
});
describe("list_changed + resourceUpdated notifications", () => {
it("dispatches toolsListChanged when the server emits notifications/tools/list_changed", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createAddToolTool()],
listChanged: { tools: true },
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
const fired = waitForEvent(client, "toolsListChanged", {
timeout: 5000,
});
const addToolTool = await getTool(client, "add_tool");
await client.callTool(addToolTool, {
name: "newly_added",
description: "added at runtime",
});
await fired;
});
it("dispatches resourcesListChanged when the server emits resources/list_changed", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createAddResourceTool()],
resources: createNumberedResources(1),
listChanged: { resources: true },
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
const fired = waitForEvent(client, "resourcesListChanged", {
timeout: 5000,
});
const addResourceTool = await getTool(client, "add_resource");
await client.callTool(addResourceTool, {
uri: "res://new",
name: "new",
text: "hi",
});
await fired;
});
it("dispatches promptsListChanged when the server emits prompts/list_changed", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createAddPromptTool()],
prompts: [createArgsPrompt()],
listChanged: { prompts: true },
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
const fired = waitForEvent(client, "promptsListChanged", {
timeout: 5000,
});
const addPromptTool = await getTool(client, "add_prompt");
await client.callTool(addPromptTool, {
name: "new_prompt",
description: "added at runtime",
promptString: "hello",
});
await fired;
});
});
describe("misc tiny branches", () => {
it("connect() is a no-op when status is already 'connected'", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
expect(client.getStatus()).toBe("connected");
// Second connect() should hit the early-return branch
await client.connect();
expect(client.getStatus()).toBe("connected");
});
it("connect() throws 'Client not initialized' when this.client is null", async () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
(c as unknown as { client: unknown }).client = null;
await expect(c.connect()).rejects.toThrow(/Client not initialized/);
});
it("getTaskCapabilities() returns undefined when the server has no tasks capability", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
client = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
expect(client.getTaskCapabilities()).toBeUndefined();
});
it("elicit form mode adds form to elicitation capability (constructor branch)", () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
// Hits the `this.elicit.form` branch in the constructor's
// elicitationCap.form assignment block
elicit: { form: true },
},
);
expect(c.getStatus()).toBe("disconnected");
});
it("receiver-task internals: TTL cleanup and cancel terminate via private surface", async () => {
// Drive the private createReceiverTask + cancelReceiverTask + TTL-cleanup
// paths by reaching into the instance. These are server-driven in
// practice (tasks/cancel from server), but the existing receiver-task
// e2e tests don't exercise the cancel path; this is the focused unit
// pass the issue suggested.
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
const internal = c as unknown as {
createReceiverTask: (opts: {
ttl?: number;
initialStatus: "input_required" | "working";
statusMessage?: string;
}) => {
task: { taskId: string; status: string };
payloadPromise: Promise<unknown>;
};
cancelReceiverTask: (taskId: string) => {
taskId: string;
status: string;
};
listReceiverTasks: () => Array<{ taskId: string; status: string }>;
getReceiverTask: (taskId: string) => unknown;
getReceiverTaskPayload: (taskId: string) => Promise<unknown>;
receiverTaskRecords: Map<string, unknown>;
};
// Short TTL so the cleanup setTimeout fires in-test
const record = internal.createReceiverTask({
ttl: 50,
initialStatus: "working",
statusMessage: "running",
});
// Capture the rejection's message for the assertion below. (Not for
// unhandled-rejection suppression — `createReceiverTask` marks the
// promise handled at the source.)
const payloadResult = record.payloadPromise.catch(
(e) => (e as Error).message,
);
expect(record.task.taskId).toBeDefined();
// listReceiverTasks contains the new task
const list = internal.listReceiverTasks();
expect(list.some((t) => t.taskId === record.task.taskId)).toBe(true);
expect(internal.getReceiverTask(record.task.taskId)).toBeDefined();
// getReceiverTaskPayload on an unknown id throws InvalidParams
await expect(
internal.getReceiverTaskPayload("does-not-exist"),
).rejects.toThrow(/Unknown taskId/);
// Cancel before TTL fires
const cancelled = internal.cancelReceiverTask(record.task.taskId);
expect(cancelled.status).toBe("cancelled");
await expect(payloadResult).resolves.toBe("Task cancelled");
// Cancel again — record is in terminal state, returns existing task
const reCancel = internal.cancelReceiverTask(record.task.taskId);
expect(reCancel.status).toBe("cancelled");
// cancelReceiverTask on an unknown id throws InvalidParams
expect(() => internal.cancelReceiverTask("nope")).toThrow(
/Unknown taskId/,
);
// Drive the TTL-cleanup path: create a record with very short ttl and
// let setTimeout fire — receiverTaskRecords drops the entry.
const ttlRecord = internal.createReceiverTask({
ttl: 20,
initialStatus: "working",
statusMessage: "running",
});
await new Promise<void>((r) => setTimeout(r, 80));
expect(internal.receiverTaskRecords.has(ttlRecord.task.taskId)).toBe(
false,
);
});
});
describe("defensive guards when client is uninitialized", () => {
// These guards exist as TypeScript narrows for the `Client | null` field
// even though the constructor always assigns it. Force the field to null
// via a private-field cast so we can exercise the throw branches once,
// rather than sprinkling `if` guards through tests for each method.
//
// NOTE: keep this list in sync with the `if (!this.client) throw …`
// sites in core/mcp/inspectorClient.ts. If a guard is removed during a
// future refactor (e.g. when the field becomes `Client` instead of
// `Client | null`) this test will silently under-cover rather than
// fail — the matching call below should be removed at the same time.
function nullify(c: InspectorClient): void {
(c as unknown as { client: unknown }).client = null;
}
it("public methods that require a client throw or short-circuit when client is null", async () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
nullify(c);
const expectThrow = async (
fn: () => Promise<unknown>,
msg = "Client is not connected",
) => {
await expect(fn()).rejects.toThrow(msg);
};
// listing / pagination
await expectThrow(() => c.listTools());
await expectThrow(() => c.listResources());
await expectThrow(() => c.listResourceTemplates());
await expectThrow(() => c.listPrompts());
// aggregate (all-page, cache-aware) listing (#1721)
await expectThrow(() => c.listAllTools());
await expectThrow(() => c.listAllResources());
await expectThrow(() => c.listAllResourceTemplates());
await expectThrow(() => c.listAllPrompts());
// single-item fetch
await expectThrow(() =>
c.callTool({ name: "x" } as unknown as Tool, {}),
);
await expectThrow(() => c.readResource("res://x"));
await expectThrow(() =>
c.readResourceFromTemplate("file:///{p}", { p: "x" }),
);
await expectThrow(() => c.getPrompt("x"));
// logging guard fires the "not connected" branch first
await expectThrow(() => c.setLoggingLevel("info"));
// roots + subscriptions
await expectThrow(() => c.setRoots([]));
await expectThrow(() => c.subscribeToResource("res://x"));
await expectThrow(() => c.unsubscribeFromResource("res://x"));
// requestor task ops
await expectThrow(() => c.getRequestorTask("t"));
await expectThrow(() => c.getRequestorTaskResult("t"));
await expectThrow(() => c.cancelRequestorTask("t"));
await expectThrow(() => c.listRequestorTasks());
// ping
await expectThrow(() => c.ping(), "Client not initialized");
// callToolStream: throws synchronously? Actually returns Promise from
// the iterator's first .next() — call and assert rejection.
await expectThrow(() =>
c.callToolStream({ name: "x" } as unknown as Tool, {}),
);
// getCompletions short-circuits to { values: [] } rather than throwing
await expect(
c.getCompletions({ type: "ref/prompt", name: "x" }, "arg", "val"),
).resolves.toEqual({ values: [] });
});
});
describe("OAuth + stdio transport", () => {
it("authenticate() throws because stdio transports have no server URL", async () => {
const c = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: {
transport: createTransportNode,
oauth: {
// Minimal stubs — providers won't ever be reached because
// getServerUrl throws first.
storage: {} as never,
navigation: {} as never,
redirectUrlProvider: {} as never,
},
},
},
);
// ensureOAuthManager hits the manager (since oauth env was supplied)
// and the manager calls getServerUrl() which throws for stdio.
await expect(c.authenticate()).rejects.toThrow(
/OAuth is only supported for HTTP-based transports/,
);
});
});
});
});