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, RequestMetadata, } from "@inspector/core/mcp/types.js"; import type { InspectorLogger } from "@inspector/core/logging/logger.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 { Client, 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 { 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 { 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 local:gate` 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; } function settleInFlight(call: Promise): 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 { tearingDown = true; await client.disconnect(); await settled; }, }; } /** Get all resources from the client via listResources() (paginates if needed). */ async function getAllResources( client: InspectorClient, metadata?: RequestMetadata, ): Promise { 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?: RequestMetadata, ): Promise { 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?: RequestMetadata, ): Promise { 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(() => {}), 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 { const params = (req.message as { params?: { _meta?: unknown } }).params; return (params?._meta as Record) ?? {}; } 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("sends a structured default _meta value as JSON, not as a string (#1910)", async () => { // The whole point of #1910: a `_meta` value may be an object, an array, // a number, a boolean or `null`. The Inspector used to type this map as // `Record`, so a nested payload could only be sent by // stringifying it — which is a different wire message than the one the // user configured. const structured = { trace: { id: "abc123", sampled: true, hops: [1, 2] }, retries: 3, beta: false, unset: null, }; client = new InspectorClient( { type: "stdio", command: serverCommand.command, args: serverCommand.args, }, { environment: { transport: createTransportNode }, defaultMetadata: structured, }, ); 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(); expect(metaOf(listToolsReq!)).toMatchObject(structured); messageLogState.destroy(); }); it.each([ ["a float", 3.5], ["an object", { id: 1 }], ["an array", [1]], ["null", null], ["a boolean", true], // Past `Number.MAX_SAFE_INTEGER` the float64 value is no longer the // integer it was written as, and zod 4's `.int()` rejects it — so // `Number.isInteger` is too loose a check here. ["an unsafe integer", Number.MAX_SAFE_INTEGER + 1], ])( "drops a reserved progressToken that is %s rather than sending it", async (_label, badToken) => { // Every other `_meta` value may be any JSON (#1910), but the spec // constrains `progressToken` to `string | integer` // (`ProgressTokenSchema`). Sending a bad one would make a conforming // server reject the whole request, so the member is dropped — the key // is optional, so what goes out is still well-formed. // // `progress: false` is what makes this observable. With progress on // (the default) the SDK sets `onprogress` and stamps its own message // id over `_meta.progressToken`, so a caller's value never reaches the // wire either way; with it off nothing overwrites the member and a bad // one would ship. client = new InspectorClient( { type: "stdio", command: serverCommand.command, args: serverCommand.args, }, { environment: { transport: createTransportNode }, progress: false, defaultMetadata: { progressToken: badToken, keep: "me" }, }, ); 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(); const meta = metaOf(listToolsReq!); expect(meta).not.toHaveProperty("progressToken"); // Only the offending member is dropped, not the whole payload. expect(meta.keep).toBe("me"); messageLogState.destroy(); }, ); it.each([ ["an array", [1]], ["a string", "t1"], ["an object with a non-string taskId", { taskId: 5 }], ])( "drops a related-task member that is %s, keeping its siblings", async (_label, badRelatedTask) => { // `progressToken` is not the only reserved member: the pinned SDK also // constrains `io.modelcontextprotocol/related-task` to // `{ taskId: string }`. A bad one makes a conforming server reject the // whole request, so it is dropped the same way. client = new InspectorClient( { type: "stdio", command: serverCommand.command, args: serverCommand.args, }, { environment: { transport: createTransportNode }, progress: false, defaultMetadata: { "io.modelcontextprotocol/related-task": badRelatedTask, keep: "me", }, }, ); const messageLogState = new MessageLogState(client); await client.connect(); await client.listTools(); const req = messageLogState .getMessages() .find( (m) => m.direction === "request" && (m.message as { method?: string }).method === "tools/list", ); const meta = metaOf(req!); expect(meta).not.toHaveProperty("io.modelcontextprotocol/related-task"); expect(meta.keep).toBe("me"); messageLogState.destroy(); }, ); it("keeps a well-formed related-task member", async () => { client = new InspectorClient( { type: "stdio", command: serverCommand.command, args: serverCommand.args, }, { environment: { transport: createTransportNode }, progress: false, defaultMetadata: { "io.modelcontextprotocol/related-task": { taskId: "task-1" }, }, }, ); const messageLogState = new MessageLogState(client); await client.connect(); await client.listTools(); const req = messageLogState .getMessages() .find( (m) => m.direction === "request" && (m.message as { method?: string }).method === "tools/list", ); expect(metaOf(req!)["io.modelcontextprotocol/related-task"]).toEqual({ taskId: "task-1", }); messageLogState.destroy(); }); it("warns with the rejected progressToken's type, never its value", async () => { // This logger is persisted by real clients (the TUI writes it to // `~/.mcp-inspector/auth.log`), and an invalid `progressToken` can now be // an object holding credentials — so the warning must not echo it. const warn = vi.fn(); const noop = () => {}; const logger: InspectorLogger = { level: "warn", fatal: noop, error: noop, warn, info: noop, debug: noop, trace: noop, silent: noop, child: () => logger, }; client = new InspectorClient( { type: "stdio", command: serverCommand.command, args: serverCommand.args, }, { environment: { transport: createTransportNode, logger }, progress: false, defaultMetadata: { progressToken: { accessToken: "sk-live-should-not-appear" }, }, }, ); await client.connect(); await client.listTools(); // The offending key travels in the log's bindings, not in the message — // the message is shared by every reserved member the sanitizer drops. const call = warn.mock.calls.find( (c) => (c[0] as { key?: string })?.key === "progressToken", ); expect(call).toBeDefined(); expect(call![0]).toEqual({ key: "progressToken", received: "object" }); // Belt and braces: the secret must not appear anywhere in the record. expect(JSON.stringify(warn.mock.calls)).not.toContain("sk-live"); }); it.each([ ["a string", "tok-1"], ["an integer", 7], // The boundary itself is still valid. ["the largest safe integer", Number.MAX_SAFE_INTEGER], ])("keeps a valid progressToken that is %s", async (_label, token) => { client = new InspectorClient( { type: "stdio", command: serverCommand.command, args: serverCommand.args, }, { environment: { transport: createTransportNode }, // See the note above: with progress on, the SDK overwrites this. progress: false, defaultMetadata: { progressToken: token }, }, ); 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(metaOf(listToolsReq!).progressToken).toBe(token); 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) ?? {}; 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 } } ).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((_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( (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((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((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((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( (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( (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( (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((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[] => { const statesByCity: Record = { "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 => { // 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; 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; result: CallToolResult | null; timestamp: Date; success: boolean; error?: string; metadata?: RequestMetadata; }> = []; 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( 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( 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; }; 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( 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( 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; it still // reports an outcome so callers have one shape to read (#2144). await expect(c.clearOAuthTokens()).resolves.toEqual({ status: "skipped", reason: "no_tokens", }); }); 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 the SDK client itself 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 appClient = c.getAppRendererClient(); expect(appClient).not.toBeNull(); // The same instance on every call — no per-call wrapper. ext-apps 2.x // peers on SDK v2 and its `AppBridge` registers list-changed forwarding // with `setNotificationHandler("notifications/…", handler)`, so the // v1-peer translation proxy is gone (#1745). expect(c.getAppRendererClient()).toBe(appClient); expect(appClient).toBeInstanceOf(Client); // The string-first registration the bridge performs must land on it // directly and fire on the real notification. expect(() => appClient?.setNotificationHandler( "notifications/prompts/list_changed", () => {}, ), ).not.toThrow(); // Disconnecting withdraws it again. await c.disconnect(); expect(c.getAppRendererClient()).toBeNull(); }); }); 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; }; cancelReceiverTask: (taskId: string) => { taskId: string; status: string; }; listReceiverTasks: () => Array<{ taskId: string; status: string }>; getReceiverTask: (taskId: string) => unknown; getReceiverTaskPayload: (taskId: string) => Promise; receiverTaskRecords: Map; }; // 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((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, 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/, ); }); }); }); });