From 38c9fcb5aef05740944d9b154758ad03f8fab8a3 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Fri, 23 Oct 2026 02:25:59 +0530 Subject: [PATCH] feat(cli)!: return MCP list result envelopes in JSON mode --- CHANGELOG.md | 3 +- README.md | 17 +- docs/REFERENCE.md | 32 +- docs/examples/company-lookup.sh | 2 +- docs/vhs/scripting.tape | 4 +- skills/mcpc/SKILL.md | 14 +- src/cli/commands/prompts.ts | 9 +- src/cli/commands/resources.ts | 9 +- src/cli/commands/tasks.ts | 12 +- src/cli/commands/tools.ts | 2 +- src/cli/index.ts | 33 +- src/core/mcp-client.ts | 116 +++++-- src/lib/types.ts | 2 + src/lib/utils.ts | 33 ++ test/e2e/suites/basic/help.test.sh | 26 ++ test/e2e/suites/basic/json-schema.test.sh | 49 +-- test/e2e/suites/basic/prompts.test.sh | 14 +- test/e2e/suites/basic/resources.test.sh | 12 +- test/e2e/suites/sessions/async-tasks.test.sh | 5 +- test/e2e/suites/sessions/pagination.test.sh | 15 +- .../suites/sessions/tasks-extension.test.sh | 3 + test/unit/cli/list-results.test.ts | 303 +++++++++++++++++ test/unit/core/list-results.test.ts | 314 ++++++++++++++++++ test/unit/lib/utils.test.ts | 23 ++ 24 files changed, 946 insertions(+), 106 deletions(-) create mode 100644 test/unit/cli/list-results.test.ts create mode 100644 test/unit/core/list-results.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd3225a..03fcbf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Each version opens with a one-line summary, which also opens its GitHub release. ## [Unreleased] -Argument completions for prompts and resource templates, tasks on MCP 2026-07-28 servers, MCP TypeScript SDK 2.3.1, stdio server secrets kept out of plaintext files, logs and `ps`, and no more duplicate bridges on Windows. +MCP result objects for JSON lists, argument completions, tasks on MCP 2026-07-28, SDK 2.3.1, and session security improvements. ### Added @@ -19,6 +19,7 @@ Argument completions for prompts and resource templates, tasks on MCP 2026-07-28 ### Changed +- **Breaking JSON change:** `tools-list`, `prompts-list`, and `resources-list` now return MCP result objects (`{ tools: [...] }`, `{ prompts: [...] }`, `{ resources: [...] }`) instead of bare arrays; update `jq '.[]'` filters to `.tools[]`, `.prompts[]`, or `.resources[]` (`tasks-list` keeps `{ tasks: [...] }`). These four lists combine all pages without `nextCursor`, retaining other top-level fields and `_meta` keys in page order (later values win); human-readable output is unchanged. (#183) - Updated to MCP TypeScript SDK 2.3.1. Large tool results over Streamable HTTP now arrive in a fraction of the time, and HTTP redirects are followed only within the server's own origin (plus `http` to `https` on the same host): a server that redirects elsewhere is reported with the target URL so you can connect to it directly. - `tools-call --detach --json` prints the whole created `Task` object (id, status, TTL, poll interval) instead of just `{ taskId, status }`. On a 2026-07-28 server that runs a detached call synchronously — the server decides per call whether to create a task — it prints the tool result instead, so check for `taskId`. - `tasks-cancel` on a 2026-07-28 server reports the status the server shows right after acknowledging the request (cancellation is cooperative there, so a task may still be `working` for a moment) and exits with code 2 only when the task had already finished. diff --git a/README.md b/README.md index f6f7d499..fc958502 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,21 @@ With `--json` option, `mcpc` always emits only a single JSON object (or array), [MCP specification](https://modelcontextprotocol.io/specification/latest).** On success, the JSON object is printed to stdout, on error to stderr. +`tools-list`, `prompts-list`, `resources-list`, and `tasks-list` return MCP +`ListToolsResult`, `ListPromptsResult`, `ListResourcesResult`, and `ListTasksResult` +objects: `{ "tools": [...] }`, `{ "prompts": [...] }`, `{ "resources": [...] }`, +and `{ "tasks": [...] }`. Empty lists keep the same object shape with an empty array. +mcpc fetches all pages automatically and omits `nextCursor`. Other top-level fields +and `_meta` keys are shallowly merged in page order, with later values winning on +duplicate keys. On MCP 2026-07-28, `tasks-list` uses the same `{ "tasks": [...] }` +shape for the tasks this session created, since the tasks extension has no listing. + +**Breaking JSON migration:** `tools-list`, `prompts-list`, and `resources-list` +previously returned bare arrays. Update `jq '.[]'` filters to `jq '.tools[]'`, +`jq '.prompts[]'`, or `jq '.resources[]'`, respectively (for example, +`jq -r '.tools[].name'`). `tasks-list` already returned an object and keeps its +`jq '.tasks[]'` filter. Human-readable output is unchanged. + Note that `--json` is not available for `mcpc --help`. For `login`, `--json` prints a single `{ profile, serverUrl, scopes }` object on stdout (interactive prompts go to stderr). @@ -718,7 +733,7 @@ mcpc --json @apify tools-call search-actors keywords:="scraper" \ | xargs -I {} mcpc @apify tools-call get-actor actorId:="{}" # Batch operations -for tool in $(mcpc --json @server tools-list | jq -r '.[].name'); do +for tool in $(mcpc --json @server tools-list | jq -r '.tools[].name'); do mcpc --json @server tools-get "$tool" > "schemas/$tool.json" done ``` diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7eace2c0..413faf9c 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -600,9 +600,11 @@ Options: --json Output in JSON format JSON output (--json): - Array of `Tool` objects: - `[{ name, description?, inputSchema, outputSchema?, annotations? }, ...]` - Schema: https://modelcontextprotocol.io/specification/2026-07-28/schema#tool + `ListToolsResult` object: + `{ tools: Tool[], _meta?: { ... }, ... }` + All pages combined; nextCursor omitted. Other top-level fields and _meta keys + merge shallowly in page order (later values win). + Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#listtoolsresult ``` ### `mcpc @ tools-get` @@ -696,11 +698,15 @@ Notes: has no listing, so there this shows the tasks this session created. JSON output (--json): - `{ tasks: Task[] }`, each `Task` shaped: + `ListTasksResult` object; each `Task` follows the negotiated protocol: + `{ tasks: Task[], _meta?: { ... }, ... }` on MCP 2025-11-25: `{ taskId, status, ttl, createdAt, lastUpdatedAt, statusMessage?, pollInterval? }` on MCP 2026-07-28: `{ taskId, status, ttlMs, createdAt, lastUpdatedAt, statusMessage?, pollIntervalMs?, result? (completed), error? (failed), inputRequests? (input_required) }` - Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#task + All legacy pages combined; nextCursor omitted. Other top-level fields and _meta keys + merge shallowly in page order (later values win). + Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#listtasksresult + https://modelcontextprotocol.io/specification/2025-11-25/schema#task https://github.com/modelcontextprotocol/ext-tasks/blob/main/specification/2026-07-28/tasks.md ``` @@ -781,9 +787,11 @@ Options: --json Output in JSON format JSON output (--json): - Array of `Prompt` objects: - `[{ name, description?, arguments?: [{ name, required? }] }, ...]` - Schema: https://modelcontextprotocol.io/specification/2026-07-28/schema#prompt + `ListPromptsResult` object: + `{ prompts: Prompt[], _meta?: { ... }, ... }` + All pages combined; nextCursor omitted. Other top-level fields and _meta keys + merge shallowly in page order (later values win). + Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#listpromptsresult ``` ### `mcpc @ prompts-get` @@ -859,9 +867,11 @@ Options: --json Output in JSON format JSON output (--json): - Array of `Resource` objects: - `[{ uri, name, description?, mimeType? }, ...]` - Schema: https://modelcontextprotocol.io/specification/2026-07-28/schema#resource + `ListResourcesResult` object: + `{ resources: Resource[], _meta?: { ... }, ... }` + All pages combined; nextCursor omitted. Other top-level fields and _meta keys + merge shallowly in page order (later values win). + Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#listresourcesresult ``` ### `mcpc @ resources-read` diff --git a/docs/examples/company-lookup.sh b/docs/examples/company-lookup.sh index 3e6116b8..260bb3aa 100755 --- a/docs/examples/company-lookup.sh +++ b/docs/examples/company-lookup.sh @@ -85,7 +85,7 @@ fi # Verify the required tool is available info "🔧 Checking for required tool..." -TOOL_EXISTS=$(mcpc --json "${SESSION}" tools-list 2>/dev/null | jq -r ".[] | select(.name == \"${REQUIRED_TOOL}\") | .name" 2>/dev/null || echo "") +TOOL_EXISTS=$(mcpc --json "${SESSION}" tools-list 2>/dev/null | jq -r ".tools[] | select(.name == \"${REQUIRED_TOOL}\") | .name" 2>/dev/null || echo "") if [ -z "$TOOL_EXISTS" ]; then error "Required tool '${REQUIRED_TOOL}' not found on server. diff --git a/docs/vhs/scripting.tape b/docs/vhs/scripting.tape index 267f4b8b..b3eeaf74 100644 --- a/docs/vhs/scripting.tape +++ b/docs/vhs/scripting.tape @@ -39,7 +39,7 @@ Enter Sleep 1500ms Show -Type "mcpc --json @apify tools-list | jq -r '.[].name'" +Type "mcpc --json @apify tools-list | jq -r '.tools[].name'" Sleep 300ms Enter Sleep 3000ms @@ -51,7 +51,7 @@ Enter Sleep 3500ms Enter -Type "for tool in $(mcpc --json @apify tools-list | jq -r '.[].name'); do" +Type "for tool in $(mcpc --json @apify tools-list | jq -r '.tools[].name'); do" Sleep 200ms Enter Type ' mcpc --json @apify tools-get "$tool" > "/tmp/$tool.json"' diff --git a/skills/mcpc/SKILL.md b/skills/mcpc/SKILL.md index 71837c45..493d96e8 100644 --- a/skills/mcpc/SKILL.md +++ b/skills/mcpc/SKILL.md @@ -144,12 +144,24 @@ echo '{"query":"hello"}' | mcpc @apify tools-call search Add `--json` for machine-readable output: results on stdout, errors on stderr, shaped strictly per the MCP spec. +`tools-list`, `prompts-list`, `resources-list`, and `tasks-list` return result +objects with `tools`, `prompts`, `resources`, and `tasks` arrays, respectively, +including empty arrays. All pages are combined and `nextCursor` is omitted; +other top-level fields and `_meta` keys are shallowly merged in page order +(later values win). On MCP 2026-07-28, `tasks-list` lists the tasks this session +created in the same object shape. + +**Breaking migration:** tools, prompts, and resources lists previously returned +bare arrays. Replace `jq '.[]'` with `jq '.tools[]'`, `jq '.prompts[]'`, or +`jq '.resources[]'`; `tasks-list` keeps its existing `jq '.tasks[]'` filter. +Human-readable lists are unchanged. + Human-readable tool results omit text blocks that duplicate `structuredContent`. When other content remains, a hint points to `--json` for the structured data; otherwise it is printed directly. JSON output always includes the full result. ```bash -mcpc --json @apify tools-list | jq -r '.[].name' +mcpc --json @apify tools-list | jq -r '.tools[].name' mcpc --json @apify tools-call search query:="test" | jq -r '.content[0].text' mcpc --json @apify tools-call search query:="test" | jq '.structuredContent' diff --git a/src/cli/commands/prompts.ts b/src/cli/commands/prompts.ts index 6ea13e38..2a88a404 100644 --- a/src/cli/commands/prompts.ts +++ b/src/cli/commands/prompts.ts @@ -6,7 +6,7 @@ import type { CommandOptions } from '../../lib/types.js'; import { formatOutput } from '../output.js'; import { withMcpClient } from '../helpers.js'; import { readCommandArgs, stringifyArgValues } from '../parser.js'; -import { fetchAllPages } from '../../lib/utils.js'; +import { fetchAllPagesResult } from '../../lib/utils.js'; /** * List available prompts @@ -15,13 +15,10 @@ import { fetchAllPages } from '../../lib/utils.js'; export async function listPrompts(target: string, options: CommandOptions): Promise { await withMcpClient(target, options, async (client, _context) => { // Fetch all prompts across all pages - const allPrompts = await fetchAllPages( - (cursor) => client.listPrompts(cursor), - (page) => page.prompts - ); + const result = await fetchAllPagesResult((cursor) => client.listPrompts(cursor), 'prompts'); console.log( - formatOutput(allPrompts, options.outputMode, { + formatOutput(options.outputMode === 'json' ? result : result.prompts, options.outputMode, { ...(options.maxChars && { maxChars: options.maxChars }), }) ); diff --git a/src/cli/commands/resources.ts b/src/cli/commands/resources.ts index 7712a189..f242e432 100644 --- a/src/cli/commands/resources.ts +++ b/src/cli/commands/resources.ts @@ -11,7 +11,7 @@ import { formatDirectoryChildren, } from '../output.js'; import { withMcpClient } from '../helpers.js'; -import { resolvePath, fetchAllPages } from '../../lib/utils.js'; +import { resolvePath, fetchAllPages, fetchAllPagesResult } from '../../lib/utils.js'; import { selectResourceContent, writeResourceFile } from '../../lib/resource-content.js'; import { ClientError } from '../../lib/errors.js'; import type { CommandOptions } from '../../lib/types.js'; @@ -23,13 +23,10 @@ import type { CommandOptions } from '../../lib/types.js'; export async function listResources(target: string, options: CommandOptions): Promise { await withMcpClient(target, options, async (client, _context) => { // Fetch all resources across all pages - const allResources = await fetchAllPages( - (cursor) => client.listResources(cursor), - (page) => page.resources - ); + const result = await fetchAllPagesResult((cursor) => client.listResources(cursor), 'resources'); console.log( - formatOutput(allResources, options.outputMode, { + formatOutput(options.outputMode === 'json' ? result : result.resources, options.outputMode, { ...(options.maxChars && { maxChars: options.maxChars }), }) ); diff --git a/src/cli/commands/tasks.ts b/src/cli/commands/tasks.ts index d8224eed..8deebf59 100644 --- a/src/cli/commands/tasks.ts +++ b/src/cli/commands/tasks.ts @@ -19,7 +19,7 @@ import { isExtensionTask, type CommandOptions } from '../../lib/types.js'; import { withMcpClient } from '../helpers.js'; import { formatTask, formatTasks } from '../output.js'; import { renderCallToolResult } from './tools.js'; -import { fetchAllPages } from '../../lib/utils.js'; +import { fetchAllPagesResult } from '../../lib/utils.js'; import { isModernProtocolVersion } from '../../core/protocol.js'; /** @@ -48,14 +48,12 @@ export async function getTaskResult( export async function listTasks(target: string, options: CommandOptions): Promise { await withMcpClient(target, options, async (client, _context) => { // Fetch all tasks across all pages - const [details, allTasks] = await Promise.all([ + const [details, result] = await Promise.all([ client.getServerDetails(), - fetchAllPages( - (cursor) => client.listTasks(cursor), - (page) => page.tasks - ), + fetchAllPagesResult((cursor) => client.listTasks(cursor), 'tasks'), ]); const tracked = !!details.protocolVersion && isModernProtocolVersion(details.protocolVersion); + const allTasks = result.tasks; if (options.outputMode === 'human') { if (allTasks.length === 0) { @@ -78,7 +76,7 @@ export async function listTasks(target: string, options: CommandOptions): Promis console.log(formatTaskCommandsHint(target)); } } else { - console.log(formatOutput({ tasks: allTasks }, 'json')); + console.log(formatOutput(result, 'json')); } }); } diff --git a/src/cli/commands/tools.ts b/src/cli/commands/tools.ts index d73a3d14..1586624d 100644 --- a/src/cli/commands/tools.ts +++ b/src/cli/commands/tools.ts @@ -106,7 +106,7 @@ export async function listTools( await withMcpClient(target, options, async (client, _context) => { const result = await client.listAllTools({ refreshCache: true }); console.log( - formatOutput(result.tools, options.outputMode, { + formatOutput(options.outputMode === 'json' ? result : result.tools, options.outputMode, { ...(options.full && { full: true }), ...(options.maxChars && { maxChars: options.maxChars }), sessionName: target, diff --git a/src/cli/index.ts b/src/cli/index.ts index 9ba8fe33..350c6b86 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1111,9 +1111,11 @@ ${jsonHelp('`{ tools?: Tool[], resources?: Resource[], prompts?: Prompt[], instr .addHelpText( 'after', jsonHelp( - 'Array of `Tool` objects', - '`[{ name, description?, inputSchema, outputSchema?, annotations? }, ...]`', - `${SCHEMA_BASE}#tool` + '`ListToolsResult` object', + '`{ tools: Tool[], _meta?: { ... }, ... }`\n' + + ' All pages combined; nextCursor omitted. Other top-level fields and _meta keys\n' + + ' merge shallowly in page order (later values win).', + `${LEGACY_SCHEMA_BASE}#listtoolsresult` ) ) .action(async (_options, command) => { @@ -1232,7 +1234,14 @@ ${toolsCallCombinedJsonHelp}` ${chalk.bold('Notes:')} MCP 2025-11-25 servers list all their tasks. The 2026-07-28 tasks extension has no listing, so there this shows the tasks this session created. -${jsonHelp('`{ tasks: Task[] }`, each `Task` shaped', taskJsonShapes, taskSchemaUrls)}` +${jsonHelp( + '`ListTasksResult` object; each `Task` follows the negotiated protocol', + '`{ tasks: Task[], _meta?: { ... }, ... }`\n ' + + taskJsonShapes + + '\n All legacy pages combined; nextCursor omitted. Other top-level fields and _meta keys\n' + + ' merge shallowly in page order (later values win).', + [`${LEGACY_SCHEMA_BASE}#listtasksresult`, ...taskSchemaUrls] +)}` ) .action(async (_options, command) => { await tasks.listTasks(session, getOptionsFromCommand(command)); @@ -1290,9 +1299,11 @@ ${jsonHelp('`Task` object (its state after the request)', taskJsonShapes, taskSc .addHelpText( 'after', jsonHelp( - 'Array of `Resource` objects', - '`[{ uri, name, description?, mimeType? }, ...]`', - `${SCHEMA_BASE}#resource` + '`ListResourcesResult` object', + '`{ resources: Resource[], _meta?: { ... }, ... }`\n' + + ' All pages combined; nextCursor omitted. Other top-level fields and _meta keys\n' + + ' merge shallowly in page order (later values win).', + `${LEGACY_SCHEMA_BASE}#listresourcesresult` ) ) .action(async (_options, command) => { @@ -1462,9 +1473,11 @@ ${jsonHelp( .addHelpText( 'after', jsonHelp( - 'Array of `Prompt` objects', - '`[{ name, description?, arguments?: [{ name, required? }] }, ...]`', - `${SCHEMA_BASE}#prompt` + '`ListPromptsResult` object', + '`{ prompts: Prompt[], _meta?: { ... }, ... }`\n' + + ' All pages combined; nextCursor omitted. Other top-level fields and _meta keys\n' + + ' merge shallowly in page order (later values win).', + `${LEGACY_SCHEMA_BASE}#listpromptsresult` ) ) .action(async (_options, command) => { diff --git a/src/core/mcp-client.ts b/src/core/mcp-client.ts index a8ed66f0..122d0caf 100644 --- a/src/core/mcp-client.ts +++ b/src/core/mcp-client.ts @@ -9,6 +9,12 @@ import { MAX_CACHE_TTL_MS, SdkHttpError, type ClientOptions, + type CacheableRequestOptions, + type RequestOptions, + type Request, + type RequestMethod, + type ResultTypeMap, + type StandardSchemaV1, } from '@modelcontextprotocol/client'; import type { Transport, @@ -52,7 +58,12 @@ import { import { CreatedTaskShim } from './tasks-result-shim.js'; import { createNoOpLogger, type Logger } from '../lib/logger.js'; import { ClientError, ServerError, NetworkError, isShutdownError } from '../lib/errors.js'; -import { fetchAllPages, MAX_PAGINATION_PAGES, sleep } from '../lib/utils.js'; +import { + fetchAllPagesResult, + mergeListPageFields, + MAX_PAGINATION_PAGES, + sleep, +} from '../lib/utils.js'; import { isModernProtocolVersion, isSupportedProtocolVersion, @@ -291,11 +302,36 @@ export function isExpectedProbeRejection(error: unknown): error is SdkHttpError return status >= 400 && status < 500 && status !== 401 && status !== 403; } +const onListPage = Symbol('onListPage'); +type ListPageOptions = RequestOptions & { + [onListPage]?: (page: Record) => void; +}; + +/** + * SDK 2.3.1 keeps page 1's object when aggregating a list, dropping later metadata. + * Observe validated pages at its public request boundary, before it finalizes and + * caches that object. Keep the typed list path: it owns cache invalidation, output + * validators and x-mcp-header filtering/mirroring. Each walk has its own callback, + * carried in options (never on the wire), so concurrent lists cannot mix fields. + */ +function listPageOptions( + itemsKey: string, + options: CacheableRequestOptions +): CacheableRequestOptions & ListPageOptions { + let firstPage: Record | undefined; + return { + ...options, + [onListPage](page) { + if (firstPage) mergeListPageFields(firstPage, page, itemsKey); + else firstPage = page; + }, + }; +} + /** - * The SDK client with the tasks-extension shim installed: a `tools/call` a 2026-07-28 - * server answers with a task (`resultType: "task"`) is rewritten before the SDK decodes - * it — see tasks-result-shim.ts. `_onresponse` is the SDK's documented subclass seam for - * inbound responses; every response is delegated to the base class, rewritten or not. + * Keep list metadata at the public request boundary and install the tasks-extension + * shim before decoding responses (see tasks-result-shim.ts). Both paths delegate + * validation and protocol handling to the SDK. */ class TasksAwareClient extends SDKClient { constructor( @@ -308,6 +344,49 @@ class TasksAwareClient extends SDKClient { protected override _onresponse(response: JSONRPCResponse): void { super._onresponse(this.shim.rewriteResponse(response)); } + + override listTools( + ...[params, options]: Parameters + ): Promise { + return super.listTools(params, listPageOptions('tools', options ?? {})); + } + + override listPrompts( + ...[params, options]: Parameters + ): Promise { + return super.listPrompts(params, listPageOptions('prompts', options ?? {})); + } + + override listResources( + ...[params, options]: Parameters + ): Promise { + return super.listResources(params, listPageOptions('resources', options ?? {})); + } + + override request( + request: { method: M; params?: Record }, + options?: RequestOptions + ): Promise; + override request( + request: Request, + resultSchema: T, + options?: RequestOptions + ): Promise>; + override async request( + request: Request, + schemaOrOptions?: StandardSchemaV1 | RequestOptions, + options?: RequestOptions + ): Promise { + if (schemaOrOptions && '~standard' in schemaOrOptions) { + return super.request(request, schemaOrOptions, options); + } + const result = await super.request( + request as { method: RequestMethod; params?: Record }, + schemaOrOptions + ); + (schemaOrOptions as ListPageOptions | undefined)?.[onListPage]?.(result); + return result; + } } /** @@ -325,7 +404,7 @@ export class McpClient implements IMcpClient { private requestTimeoutMillis: number = DEFAULT_REQUEST_TIMEOUT_MILLIS; /** Baseline timeout from the constructor — what resetRequestTimeout() restores. */ private readonly configuredRequestTimeoutMillis: number; - private cachedTools: Tool[] | null = null; + private cachedTools: ListToolsResult | null = null; private cachedToolsExpiresAt: number | null = null; private isClosing = false; /** @@ -694,22 +773,17 @@ export class McpClient implements IMcpClient { */ async listAllTools(options?: { refreshCache?: boolean }): Promise { if (!options?.refreshCache && this.cachedTools && !this.isToolsCacheExpired()) { - return { tools: this.cachedTools }; + return this.cachedTools; } - let firstPage: ListToolsResult | undefined; - const allTools: Tool[] = await fetchAllPages( - async (cursor) => { - const page = await this.listTools(cursor, options?.refreshCache); - firstPage ??= page; - return page; - }, - (page) => page.tools + const result = await fetchAllPagesResult( + (cursor) => this.listTools(cursor, options?.refreshCache), + 'tools' ); - this.cachedTools = allTools; - this.cachedToolsExpiresAt = this.deriveToolsCacheExpiry(firstPage); - return { tools: allTools }; + this.cachedTools = result; + this.cachedToolsExpiresAt = this.deriveToolsCacheExpiry(result); + return result; } /** @@ -726,9 +800,9 @@ export class McpClient implements IMcpClient { * bridge process serving exactly one session, i.e. one authorization context, so a * `private` result is never shared with another principal. */ - private deriveToolsCacheExpiry(firstPage: ListToolsResult | undefined): number | null { + private deriveToolsCacheExpiry(result: ListToolsResult): number | null { // Typed `unknown` — the hint rides the result's loose passthrough fields. - const ttlMillis = firstPage?.ttlMs; + const ttlMillis = result.ttlMs; if (typeof ttlMillis === 'number') { return Date.now() + Math.min(Math.max(0, ttlMillis), MAX_CACHE_TTL_MS); } @@ -745,7 +819,7 @@ export class McpClient implements IMcpClient { * Get the cached tools list synchronously (returns null if not yet populated). */ getCachedTools(): Tool[] | null { - return this.cachedTools; + return this.cachedTools?.tools ?? null; } /** diff --git a/src/lib/types.ts b/src/lib/types.ts index 50dededd..384eea1d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -508,6 +508,8 @@ export function isExtensionTask(task: AnyTask): task is ExtensionTask { export interface TasksPage { tasks: AnyTask[]; nextCursor?: string | undefined; + _meta?: Record | undefined; + [key: string]: unknown; } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1d0645aa..44232479 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -66,6 +66,39 @@ export async function fetchAllPages, + page: Record, + itemsKey: string +): void { + for (const [key, value] of Object.entries(page)) { + if (key === itemsKey || key === 'nextCursor') continue; + setOwnProperty( + result, + key, + key === '_meta' ? { ...(result._meta as object), ...(value as object) } : value + ); + } +} + +/** Fetch a complete MCP list result without leaking a pagination cursor. */ +export async function fetchAllPagesResult< + TPage extends { nextCursor?: string | undefined; [key: string]: unknown }, + TKey extends keyof TPage & string, +>(fetchPage: (cursor?: string) => Promise, itemsKey: TKey): Promise { + const result: Record = {}; + const items = await fetchAllPages( + async (cursor) => { + const page = await fetchPage(cursor); + mergeListPageFields(result, page, itemsKey); + return page; + }, + (page) => page[itemsKey] as unknown[] + ); + return { ...result, [itemsKey]: items } as TPage; +} + /** * Expand tilde (~) to home directory in paths */ diff --git a/test/e2e/suites/basic/help.test.sh b/test/e2e/suites/basic/help.test.sh index 9c54bb2e..00d72fef 100755 --- a/test/e2e/suites/basic/help.test.sh +++ b/test/e2e/suites/basic/help.test.sh @@ -261,4 +261,30 @@ if [[ -n "$MISSING" ]]; then fi test_pass +# List command JSON envelopes are part of the scripting interface. Keep their +# schema references and automatic pagination policy discoverable from --help. +for cmd in tools-list prompts-list resources-list tasks-list; do + case "$cmd" in + tools-list) LIST_RESULT_TYPE="ListToolsResult"; LIST_ARRAY="tools: Tool[]" ;; + prompts-list) LIST_RESULT_TYPE="ListPromptsResult"; LIST_ARRAY="prompts: Prompt[]" ;; + resources-list) LIST_RESULT_TYPE="ListResourcesResult"; LIST_ARRAY="resources: Resource[]" ;; + tasks-list) LIST_RESULT_TYPE="ListTasksResult"; LIST_ARRAY="tasks: Task[]" ;; + esac + test_case "$cmd help documents its MCP list result and pagination" + run_mcpc @test-session "$cmd" --help + assert_success + assert_contains "$STDOUT" "JSON output (--json):" + assert_contains "$STDOUT" "$LIST_RESULT_TYPE" + assert_contains "$STDOUT" "$LIST_ARRAY" + assert_contains "$STDOUT" "https://modelcontextprotocol.io/specification/2025-11-25/schema#$(echo "$LIST_RESULT_TYPE" | tr '[:upper:]' '[:lower:]')" + assert_contains "$STDOUT" "nextCursor omitted" + assert_contains "$STDOUT" "Other top-level fields and _meta keys" + assert_contains "$STDOUT" "merge shallowly in page order (later values win)" + if [[ "$cmd" == "tasks-list" ]]; then + assert_contains "$STDOUT" "2026-07-28 tasks extension" + assert_contains "$STDOUT" "tasks this session created" + fi + test_pass +done + test_done diff --git a/test/e2e/suites/basic/json-schema.test.sh b/test/e2e/suites/basic/json-schema.test.sh index 6ff255c5..5e731a44 100755 --- a/test/e2e/suites/basic/json-schema.test.sh +++ b/test/e2e/suites/basic/json-schema.test.sh @@ -118,28 +118,31 @@ test_pass # MCP spec: ListToolsResult contains tools array # ============================================================================= -test_case "tools-list JSON is valid array" +test_case "tools-list JSON is ListToolsResult" run_mcpc "$SESSION" tools-list --json assert_success assert_json_valid "$STDOUT" -# Result should be an array of tools -assert_json "$STDOUT" '.[0]' "should be a non-empty array" +# Result should contain the tools array +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.tools | type == "array"' +assert_json "$STDOUT" '.tools[0]' "should have a non-empty tools array" +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass test_case "tools-list JSON tool has required fields" run_mcpc "$SESSION" tools-list --json assert_success # Per MCP spec, Tool must have: name, inputSchema -assert_json "$STDOUT" '.[0].name' "tool should have name" -assert_json "$STDOUT" '.[0].inputSchema' "tool should have inputSchema" +assert_json "$STDOUT" '.tools[0].name' "tool should have name" +assert_json "$STDOUT" '.tools[0].inputSchema' "tool should have inputSchema" test_pass test_case "tools-list JSON tool inputSchema has type" run_mcpc "$SESSION" tools-list --json assert_success # inputSchema must be a valid JSON Schema object -assert_json "$STDOUT" '.[0].inputSchema.type' "inputSchema should have type" -input_type=$(json_get '.[0].inputSchema.type') +assert_json "$STDOUT" '.tools[0].inputSchema.type' "inputSchema should have type" +input_type=$(json_get '.tools[0].inputSchema.type') assert_eq "$input_type" "object" "inputSchema type should be 'object'" test_pass @@ -147,7 +150,7 @@ test_case "tools-list JSON tool has optional description" run_mcpc "$SESSION" tools-list --json assert_success # description is optional but test server provides it -assert_json "$STDOUT" '.[0].description' "tool should have description" +assert_json "$STDOUT" '.tools[0].description' "tool should have description" test_pass # ============================================================================= @@ -170,27 +173,30 @@ test_pass # MCP spec: ListResourcesResult contains resources array # ============================================================================= -test_case "resources-list JSON is valid array" +test_case "resources-list JSON is ListResourcesResult" run_mcpc "$SESSION" resources-list --json assert_success assert_json_valid "$STDOUT" -# Result should be an array of resources -assert_json "$STDOUT" '.[0]' "should be a non-empty array" +# Result should contain the resources array +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.resources | type == "array"' +assert_json "$STDOUT" '.resources[0]' "should have a non-empty resources array" +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass test_case "resources-list JSON resource has required uri" run_mcpc "$SESSION" resources-list --json assert_success # Per MCP spec, Resource must have: uri -assert_json "$STDOUT" '.[0].uri' "resource should have uri" +assert_json "$STDOUT" '.resources[0].uri' "resource should have uri" test_pass test_case "resources-list JSON resource has optional fields" run_mcpc "$SESSION" resources-list --json assert_success # Test server provides these optional fields -assert_json "$STDOUT" '.[0].name' "resource should have name" -assert_json "$STDOUT" '.[0].mimeType' "resource should have mimeType" +assert_json "$STDOUT" '.resources[0].name' "resource should have name" +assert_json "$STDOUT" '.resources[0].mimeType' "resource should have mimeType" test_pass # ============================================================================= @@ -218,28 +224,31 @@ test_pass # MCP spec: ListPromptsResult contains prompts array # ============================================================================= -test_case "prompts-list JSON is valid array" +test_case "prompts-list JSON is ListPromptsResult" run_mcpc "$SESSION" prompts-list --json assert_success assert_json_valid "$STDOUT" -# Result should be an array of prompts -assert_json "$STDOUT" '.[0]' "should be a non-empty array" +# Result should contain the prompts array +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.prompts | type == "array"' +assert_json "$STDOUT" '.prompts[0]' "should have a non-empty prompts array" +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass test_case "prompts-list JSON prompt has required name" run_mcpc "$SESSION" prompts-list --json assert_success # Per MCP spec, Prompt must have: name -assert_json "$STDOUT" '.[0].name' "prompt should have name" +assert_json "$STDOUT" '.prompts[0].name' "prompt should have name" test_pass test_case "prompts-list JSON prompt arguments have required fields" run_mcpc "$SESSION" prompts-list --json assert_success # Per MCP spec, PromptArgument must have: name, required (boolean) -assert_json "$STDOUT" '.[0].arguments[0].name' "argument should have name" +assert_json "$STDOUT" '.prompts[0].arguments[0].name' "argument should have name" # required field should be boolean -arg_required=$(json_get '.[0].arguments[0].required') +arg_required=$(json_get '.prompts[0].arguments[0].required') if [[ "$arg_required" != "true" && "$arg_required" != "false" ]]; then test_fail "argument.required should be boolean, got: $arg_required" fi diff --git a/test/e2e/suites/basic/prompts.test.sh b/test/e2e/suites/basic/prompts.test.sh index 820772e8..ef7d63fb 100755 --- a/test/e2e/suites/basic/prompts.test.sh +++ b/test/e2e/suites/basic/prompts.test.sh @@ -50,26 +50,28 @@ assert_contains "$STDOUT" "name" assert_contains "$STDOUT" "[required]" test_pass -test_case "prompts-list --json returns valid array" +test_case "prompts-list --json returns ListPromptsResult" run_mcpc --json "$SESSION" prompts-list assert_success assert_json_valid "$STDOUT" -assert_json "$STDOUT" '. | type == "array"' -assert_json "$STDOUT" '. | length == 2' +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.prompts | type == "array"' +assert_json "$STDOUT" '.prompts | length == 2' +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass test_case "prompts-list --json contains expected fields" run_mcpc --json "$SESSION" prompts-list assert_success -assert_json "$STDOUT" '.[0].name' -assert_json "$STDOUT" '.[0].description' +assert_json "$STDOUT" '.prompts[0].name' +assert_json "$STDOUT" '.prompts[0].description' test_pass test_case "prompts-list --json contains arguments" run_mcpc --json "$SESSION" prompts-list assert_success # greeting prompt has arguments -greeting=$(echo "$STDOUT" | jq '.[] | select(.name == "greeting")') +greeting=$(echo "$STDOUT" | jq '.prompts[] | select(.name == "greeting")') assert_json "$greeting" '.arguments' assert_json "$greeting" '.arguments | length > 0' test_pass diff --git a/test/e2e/suites/basic/resources.test.sh b/test/e2e/suites/basic/resources.test.sh index bf679767..9fa538ae 100755 --- a/test/e2e/suites/basic/resources.test.sh +++ b/test/e2e/suites/basic/resources.test.sh @@ -49,20 +49,22 @@ assert_success assert_contains "$STDOUT" "static test resource" test_pass -test_case "resources-list --json returns valid array" +test_case "resources-list --json returns ListResourcesResult" run_mcpc --json "$SESSION" resources-list assert_success assert_json_valid "$STDOUT" -assert_json "$STDOUT" '. | type == "array"' -assert_json "$STDOUT" '. | length == 5' +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.resources | type == "array"' +assert_json "$STDOUT" '.resources | length == 5' +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass test_case "resources-list --json contains expected fields" run_mcpc --json "$SESSION" resources-list assert_success # Check first resource has required fields -assert_json "$STDOUT" '.[0].uri' -assert_json "$STDOUT" '.[0].name' +assert_json "$STDOUT" '.resources[0].uri' +assert_json "$STDOUT" '.resources[0].name' test_pass # ============================================================================= diff --git a/test/e2e/suites/sessions/async-tasks.test.sh b/test/e2e/suites/sessions/async-tasks.test.sh index 87ec36ee..b6fef0ee 100755 --- a/test/e2e/suites/sessions/async-tasks.test.sh +++ b/test/e2e/suites/sessions/async-tasks.test.sh @@ -64,10 +64,13 @@ assert_success assert_contains "$STDOUT" "$TASK_ID" test_pass -test_case "tasks-list --json returns tasks array" +test_case "tasks-list --json returns ListTasksResult" run_mcpc --json "$SESSION" tasks-list assert_success assert_json_valid "$STDOUT" +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.tasks | type == "array"' +assert_json "$STDOUT" 'has("nextCursor") | not' task_count=$(echo "$STDOUT" | jq '.tasks | length') assert_not_empty "$task_count" # At least the task we just started (may include the earlier one too) diff --git a/test/e2e/suites/sessions/pagination.test.sh b/test/e2e/suites/sessions/pagination.test.sh index 2b8dbf37..0ab21800 100755 --- a/test/e2e/suites/sessions/pagination.test.sh +++ b/test/e2e/suites/sessions/pagination.test.sh @@ -35,9 +35,10 @@ test_pass test_case "tools-list --json returns all tools" run_xmcpc "$SESSION" tools-list --json assert_success -# Count tools in JSON output (returns array directly) -tool_count=$(echo "$STDOUT" | jq 'length') +# Count tools in the MCP ListToolsResult envelope +tool_count=$(echo "$STDOUT" | jq '.tools | length') assert_eq "$tool_count" "6" "should have all 6 tools" +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass # Test: resources-list fetches all pages @@ -56,9 +57,10 @@ test_pass test_case "resources-list --json returns all resources" run_xmcpc "$SESSION" resources-list --json assert_success -# Returns array directly -resource_count=$(echo "$STDOUT" | jq 'length') +# Count resources in the MCP ListResourcesResult envelope +resource_count=$(echo "$STDOUT" | jq '.resources | length') assert_eq "$resource_count" "5" "should have all 5 resources" +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass # Test: prompts-list fetches all pages @@ -74,9 +76,10 @@ test_pass test_case "prompts-list --json returns all prompts" run_xmcpc "$SESSION" prompts-list --json assert_success -# Returns array directly -prompt_count=$(echo "$STDOUT" | jq 'length') +# Count prompts in the MCP ListPromptsResult envelope +prompt_count=$(echo "$STDOUT" | jq '.prompts | length') assert_eq "$prompt_count" "2" "should have all 2 prompts" +assert_json "$STDOUT" 'has("nextCursor") | not' test_pass test_done diff --git a/test/e2e/suites/sessions/tasks-extension.test.sh b/test/e2e/suites/sessions/tasks-extension.test.sh index 8e03fff1..c118d80c 100755 --- a/test/e2e/suites/sessions/tasks-extension.test.sh +++ b/test/e2e/suites/sessions/tasks-extension.test.sh @@ -211,6 +211,9 @@ assert_contains "$STDOUT" "$CANCEL_ID" assert_contains "$STDOUT" "keep no task listing" run_mcpc --json "$SESSION" tasks-list assert_success +assert_json "$STDOUT" '. | type == "object"' +assert_json "$STDOUT" '.tasks | type == "array"' +assert_json "$STDOUT" 'has("nextCursor") | not' assert_json "$STDOUT" '[.tasks[] | select(.taskId == "'"$TASK_ID"'")][0].status == "completed"' assert_json "$STDOUT" '[.tasks[] | select(.taskId == "'"$CANCEL_ID"'")][0].status == "cancelled"' test_pass diff --git a/test/unit/cli/list-results.test.ts b/test/unit/cli/list-results.test.ts new file mode 100644 index 00000000..9fead0e3 --- /dev/null +++ b/test/unit/cli/list-results.test.ts @@ -0,0 +1,303 @@ +/** Command-level coverage for the MCP list result JSON envelopes. */ + +vi.mock('../../../src/cli/helpers.js', () => ({ + withMcpClient: vi.fn(), +})); + +import { stripVTControlCharacters } from 'node:util'; +import { listTools } from '../../../src/cli/commands/tools.js'; +import { listPrompts } from '../../../src/cli/commands/prompts.js'; +import { listResources } from '../../../src/cli/commands/resources.js'; +import { listTasks } from '../../../src/cli/commands/tasks.js'; +import { withMcpClient } from '../../../src/cli/helpers.js'; +import type { IMcpClient } from '../../../src/lib/types.js'; + +const tool = { + name: 'echo', + description: 'Echo the message', + inputSchema: { + type: 'object' as const, + properties: { message: { type: 'string' } }, + required: ['message'], + }, +}; +const prompt = { + name: 'greeting', + description: 'Create a greeting', + arguments: [{ name: 'name', required: true }], +}; +const resource = { + uri: 'test://hello', + name: 'Hello Resource', + mimeType: 'text/plain', + description: 'A greeting resource', +}; +const task = { + taskId: 'task-1', + status: 'working' as const, + createdAt: '2026-01-01T00:00:00Z', + lastUpdatedAt: '2026-01-01T00:00:00Z', + ttl: null, +}; + +const paginatedCommands = [ + { + command: 'prompts-list', + key: 'prompts', + method: 'listPrompts', + item: prompt, + run: listPrompts, + }, + { + command: 'resources-list', + key: 'resources', + method: 'listResources', + item: resource, + run: listResources, + }, + { command: 'tasks-list', key: 'tasks', method: 'listTasks', item: task, run: listTasks }, +] as const; + +const commands = [ + { command: 'tools-list', key: 'tools', method: 'listAllTools', item: tool, run: listTools }, + ...paginatedCommands, +] as const; + +function useClient(client: Partial): void { + vi.mocked(withMcpClient).mockImplementation(async (_target, _options, callback) => + callback(client as never, {}) + ); +} + +function serverDetails(protocolVersion = '2025-11-25') { + return vi.fn().mockResolvedValue({ protocolVersion }); +} + +function output(): string { + return stripVTControlCharacters( + vi + .mocked(console.log) + .mock.calls.map(([value]) => String(value)) + .join('\n') + ); +} + +beforeEach(() => { + vi.mocked(withMcpClient).mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe.each(paginatedCommands)('$command JSON pagination', ({ key, method, item, run }) => { + it('merges actual command pages in order and removes the consumed cursor', async () => { + const secondItem = { ...item, name: 'second', uri: 'test://second', taskId: 'task-2' }; + const pages = [ + { + [key]: [item], + nextCursor: 'page-2', + _meta: { first: true, shared: 'first', nested: { first: true } }, + firstField: true, + sharedField: { first: true }, + }, + { + [key]: [], + nextCursor: 'page-3', + _meta: { second: true, shared: 'second', nested: { second: true } }, + sharedField: { second: true }, + }, + { + [key]: [secondItem], + _meta: { last: true, shared: 'last' }, + lastField: true, + }, + ]; + const list = vi + .fn() + .mockResolvedValueOnce(pages[0]) + .mockResolvedValueOnce(pages[1]) + .mockResolvedValueOnce(pages[2]); + useClient({ [method]: list, getServerDetails: serverDetails() }); + + await run('@s', { outputMode: 'json', maxChars: 1 }); + + expect(list.mock.calls).toEqual([[undefined], ['page-2'], ['page-3']]); + expect(console.log).toHaveBeenCalledTimes(1); + expect(JSON.parse(output())).toEqual({ + [key]: [item, secondItem], + _meta: { + first: true, + second: true, + last: true, + shared: 'last', + nested: { second: true }, + }, + firstField: true, + sharedField: { second: true }, + lastField: true, + }); + }); + + it.each([new Error('later page failed'), new DOMException('cancelled', 'AbortError')])( + 'prints no partial result when a later page rejects with %s', + async (error) => { + const list = vi + .fn() + .mockResolvedValueOnce({ [key]: [item], nextCursor: 'page-2' }) + .mockRejectedValueOnce(error); + useClient({ [method]: list, getServerDetails: serverDetails() }); + + await expect(run('@s', { outputMode: 'json' })).rejects.toBe(error); + + expect(list.mock.calls).toEqual([[undefined], ['page-2']]); + expect(console.log).not.toHaveBeenCalled(); + } + ); +}); + +describe('tools-list JSON', () => { + it('prints the complete cached result without extracting or wrapping its tools', async () => { + const result = { + tools: [tool], + _meta: { firstPage: true, lastPage: true, shared: 'last' }, + extensionField: { value: 'retained' }, + }; + const listAllTools = vi.fn().mockResolvedValue(result); + useClient({ listAllTools }); + + await listTools('@s', { outputMode: 'json', full: true, maxChars: 1 }); + + expect(listAllTools).toHaveBeenCalledExactlyOnceWith({ refreshCache: true }); + expect(console.log).toHaveBeenCalledTimes(1); + expect(JSON.parse(output())).toEqual(result); + }); + + it.each([new Error('listing failed'), new DOMException('cancelled', 'AbortError')])( + 'prints no result when listing rejects with %s', + async (error) => { + useClient({ listAllTools: vi.fn().mockRejectedValue(error) }); + + await expect(listTools('@s', { outputMode: 'json' })).rejects.toBe(error); + + expect(console.log).not.toHaveBeenCalled(); + } + ); +}); + +describe.each(commands)('$command empty JSON result', ({ key, method, run }) => { + it('retains the named empty array inside one result object', async () => { + useClient({ + [method]: vi.fn().mockResolvedValue({ [key]: [] }), + getServerDetails: serverDetails(), + }); + + await run('@s', { outputMode: 'json' }); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(JSON.parse(output())).toEqual({ [key]: [] }); + }); +}); + +describe('human list output', () => { + it('keeps the compact tools list and its session-specific hints', async () => { + useClient({ listAllTools: vi.fn().mockResolvedValue({ tools: [tool], hidden: 'metadata' }) }); + + await listTools('@s', { outputMode: 'human' }); + + expect(output()).toContain('Tools (1):'); + expect(output()).toContain('echo (message:str)'); + expect(output()).toContain('mcpc @s tools-get '); + expect(output()).not.toContain('Echo the message'); + expect(output()).not.toContain('metadata'); + }); + + it('keeps --full tool details and schema', async () => { + useClient({ listAllTools: vi.fn().mockResolvedValue({ tools: [tool], hidden: 'metadata' }) }); + + await listTools('@s', { outputMode: 'human', full: true }); + + expect(output()).toContain('Tools (1):'); + expect(output()).toContain('---'); + expect(output()).toContain('Tool: `echo`'); + expect(output()).toContain('Echo the message'); + expect(output()).toContain('Input:'); + expect(output()).toContain('`message`: string [required]'); + expect(output()).not.toContain('metadata'); + }); + + it('keeps prompts and their argument details across pages', async () => { + useClient({ + listPrompts: vi + .fn() + .mockResolvedValueOnce({ prompts: [], nextCursor: 'page-2', hidden: 'metadata' }) + .mockResolvedValueOnce({ prompts: [prompt] }), + }); + + await listPrompts('@s', { outputMode: 'human' }); + + expect(output()).toContain('Prompts (1):'); + expect(output()).toContain('Prompt: `greeting`'); + expect(output()).toContain('Arguments:'); + expect(output()).toContain('[required]'); + expect(output()).toContain('Create a greeting'); + expect(output()).not.toContain('metadata'); + }); + + it('keeps resources and their MIME type details across pages', async () => { + useClient({ + listResources: vi + .fn() + .mockResolvedValueOnce({ resources: [], nextCursor: 'page-2', hidden: 'metadata' }) + .mockResolvedValueOnce({ resources: [resource] }), + }); + + await listResources('@s', { outputMode: 'human' }); + + expect(output()).toContain('Resources (1):'); + expect(output()).toContain('Resource: `test://hello`'); + expect(output()).toContain('Hello Resource'); + expect(output()).toContain('MIME type: text/plain'); + expect(output()).not.toContain('metadata'); + }); + + it.each([ + ['2025-11-25', 'No active tasks'], + ['2026-07-28', 'No tasks started from this session'], + ])('keeps the %s empty task state', async (version, message) => { + useClient({ + listTasks: vi.fn().mockResolvedValue({ tasks: [] }), + getServerDetails: serverDetails(version), + }); + + await listTasks('@s', { outputMode: 'human' }); + + expect(output()).toContain(message); + expect(output()).toContain('mcpc @s tools-call [args] --task'); + }); + + it.each(['2025-11-25', '2026-07-28'])( + 'keeps the %s task list, commands, and protocol-era note', + async (version) => { + useClient({ + listTasks: vi.fn().mockResolvedValue({ tasks: [task], hidden: 'metadata' }), + getServerDetails: serverDetails(version), + }); + + await listTasks('@s', { outputMode: 'human' }); + + expect(output()).toContain('task-1'); + expect(output()).toContain('working'); + expect(output()).toContain('mcpc @s tasks-get '); + if (version === '2026-07-28') { + expect(output()).toContain('Tasks started from this session (1):'); + expect(output()).toContain('MCP 2026-07-28 servers keep no task listing'); + } else { + expect(output()).toContain('Tasks (1):'); + expect(output()).not.toContain('servers keep no task listing'); + } + expect(output()).not.toContain('metadata'); + } + ); +}); diff --git a/test/unit/core/list-results.test.ts b/test/unit/core/list-results.test.ts new file mode 100644 index 00000000..b0757953 --- /dev/null +++ b/test/unit/core/list-results.test.ts @@ -0,0 +1,314 @@ +/** Real SDK + HTTP transport: each response below is one protocol page, never an aggregate. */ +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { McpClient } from '../../../src/core/mcp-client.js'; +import { fetchAllPagesResult } from '../../../src/lib/utils.js'; + +type Key = 'tools' | 'prompts' | 'resources' | 'tasks'; +type Page = Record; + +const items: Record = { + tools: { name: 'first', inputSchema: { type: 'object' } }, + prompts: { name: 'first' }, + resources: { name: 'first', uri: 'test://first' }, + tasks: { + taskId: 'first', + status: 'working', + ttl: null, + createdAt: '2026-01-01T00:00:00Z', + lastUpdatedAt: '2026-01-01T00:00:00Z', + }, +}; + +function pages(key: Key): Page[] { + return [ + { + [key]: [items[key]], + nextCursor: 'second', + _meta: { first: true, overlap: 'first', nested: { first: true } }, + firstField: true, + overlap: { first: true }, + ttlMs: 60_000, + }, + { + [key]: [], + nextCursor: 'third', + _meta: { middle: true, overlap: 'middle' }, + middleField: true, + }, + { + [key]: [{ ...items[key], name: 'last', uri: 'test://last', taskId: 'last' }], + _meta: { last: true, overlap: 'last', nested: { last: true } }, + lastField: true, + overlap: { last: true }, + ttlMs: 30_000, + }, + ]; +} + +/** A fetch-level fixture: the actual pinned SDK performs decoding, pagination and caching. */ +async function fixture(modern = false) { + const requests: { method: string; params?: Record; headers: Headers }[] = []; + const results = new Map(); + let failPage = false; + let stallPage = false; + let invalidOutput = false; + let abortedPosts = 0; + const version = modern ? '2026-07-28' : '2025-11-25'; + const capabilities = { tools: {}, prompts: {}, resources: {}, tasks: { list: {} } }; + const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1/mcp'), { + fetch: async (_url, init) => { + if (init?.method === 'GET') return new Response(null, { status: 405 }); + if (init?.method === 'DELETE') return new Response(null, { status: 200 }); + const message = JSON.parse(init?.body as string); + requests.push({ ...message, headers: new Headers(init?.headers) }); + if (message.id === undefined) return new Response(null, { status: 202 }); + let result: unknown; + let error: unknown; + if (message.method === 'initialize') { + result = { + protocolVersion: version, + capabilities, + serverInfo: { name: 'pages', version: '1' }, + }; + } else if (message.method === 'server/discover') { + result = { + supportedVersions: [version], + capabilities: { tools: {}, prompts: {}, resources: {} }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'pages', version: '1' } }, + }; + } else if (message.method === 'tools/call') { + result = { + resultType: 'complete', + content: [], + structuredContent: { value: invalidOutput ? 3 : 'ok' }, + }; + } else { + const key = message.method.split('/')[0] as Key; + const index = + message.params?.cursor === 'second' ? 1 : message.params?.cursor === 'third' ? 2 : 0; + if (index === 1 && stallPage) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => { + abortedPosts++; + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true } + ); + }); + } + if (index === 1 && failPage) error = { code: -32603, message: 'second page failed' }; + else + result = { + ...(modern && { resultType: 'complete', ttlMs: 60_000, cacheScope: 'private' }), + ...(results.get(key)?.[index] ?? { [key]: [] }), + }; + } + return new Response( + JSON.stringify({ jsonrpc: '2.0', id: message.id, ...(error ? { error } : { result }) }), + { + headers: { 'Content-Type': 'application/json' }, + } + ); + }, + }); + const client = new McpClient( + { name: 'list-results-test', version: '1' }, + { protocolVersion: version } + ); + await client.connect(transport); + return { + client, + requests, + results, + fail: (value: boolean) => { + failPage = value; + }, + stall: (value: boolean) => { + stallPage = value; + }, + invalidOutput: () => { + invalidOutput = true; + }, + abortedPosts: () => abortedPosts, + }; +} + +async function read(client: McpClient, key: Key) { + switch (key) { + case 'tools': + return client.listAllTools(); + case 'prompts': + return fetchAllPagesResult((cursor) => client.listPrompts(cursor), 'prompts'); + case 'resources': + return fetchAllPagesResult((cursor) => client.listResources(cursor), 'resources'); + case 'tasks': + return fetchAllPagesResult((cursor) => client.listTasks(cursor), 'tasks'); + } +} + +describe.each([false, true])('SDK protocol pages (modern=%s)', (modern) => { + const keys: Key[] = modern + ? ['tools', 'prompts', 'resources'] + : ['tools', 'prompts', 'resources', 'tasks']; + it.each(keys)('merges all %s fields in order and retains cached metadata', async (key) => { + const f = await fixture(modern); + f.results.set(key, pages(key)); + try { + const result = await read(f.client, key); + expect(result[key]).toHaveLength(2); + expect(result).toMatchObject({ + _meta: { first: true, middle: true, last: true, overlap: 'last', nested: { last: true } }, + firstField: true, + middleField: true, + lastField: true, + overlap: { last: true }, + ttlMs: 30_000, + }); + expect(result._meta).not.toHaveProperty('nested.first'); + expect(result).not.toHaveProperty('nextCursor'); + const calls = () => f.requests.filter((r) => r.method === `${key}/list`); + expect(calls().map((r) => r.params?.cursor)).toEqual([undefined, 'second', 'third']); + if (modern) { + for (const r of calls()) { + expect(r.headers.get('Mcp-Method')).toBe(`${key}/list`); + expect(r.headers.get('MCP-Protocol-Version')).toBe('2026-07-28'); + } + } + if (key !== 'tasks') { + // Read the SDK cache, not just mcpc's outer tools cache. + const cached = key === 'tools' ? await f.client.listTools() : await read(f.client, key); + expect(cached).toEqual(result); + expect(calls()).toHaveLength(3); + } + if (key === 'tools') { + expect(await f.client.listAllTools()).toEqual(result); + expect(await f.client.listAllTools({ refreshCache: true })).toEqual(result); + expect(calls()).toHaveLength(6); + } + } finally { + await f.client.close(); + } + }); + + it.each(keys)('returns an empty %s result without manufacturing _meta', async (key) => { + const f = await fixture(modern); + try { + const result = await read(f.client, key); + expect(result[key]).toEqual([]); + expect(result).not.toHaveProperty('_meta'); + expect(result).not.toHaveProperty('nextCursor'); + if (!modern) expect(result).toEqual({ [key]: [] }); + } finally { + await f.client.close(); + } + }); + + it.each(keys)('does not cache partial %s results after a later-page error', async (key) => { + const f = await fixture(modern); + f.results.set(key, pages(key)); + f.fail(true); + try { + await expect(read(f.client, key)).rejects.toThrow('second page failed'); + if (key === 'tools') expect(f.client.getCachedTools()).toBeNull(); + f.fail(false); + expect(await read(f.client, key)).toHaveProperty('lastField', true); + expect( + f.requests.filter((r) => r.method === `${key}/list`).map((r) => r.params?.cursor) + ).toEqual([undefined, 'second', undefined, 'second', 'third']); + } finally { + await f.client.close(); + } + }); + + it.each(keys)('cancels a timed-out %s page and retries from page one', async (key) => { + const f = await fixture(modern); + f.results.set(key, pages(key)); + f.stall(true); + f.client.setRequestTimeout(30); + try { + await expect(read(f.client, key)).rejects.toThrow(/timed out|timeout/i); + // The SDK uses HTTP abort in the modern era and a notification in the legacy era. + if (modern) expect(f.abortedPosts()).toBe(1); + else expect(f.requests.some((r) => r.method === 'notifications/cancelled')).toBe(true); + f.stall(false); + f.client.resetRequestTimeout(); + expect(await read(f.client, key)).toHaveProperty('lastField', true); + } finally { + await f.client.close(); + } + }); + + it('isolates metadata in simultaneous list walks', async () => { + const f = await fixture(modern); + for (const key of ['tools', 'prompts', 'resources'] as const) { + f.results.set( + key, + pages(key).map((page) => ({ + ...page, + source: key, + _meta: { ...(page._meta as object), source: key }, + })) + ); + } + try { + const results = await Promise.all( + ['tools', 'prompts', 'resources'].map((key) => read(f.client, key as Key)) + ); + for (const [index, key] of ['tools', 'prompts', 'resources'].entries()) { + expect(results[index]).toHaveProperty('source', key); + expect(results[index]?._meta).toHaveProperty('source', key); + } + } finally { + await f.client.close(); + } + }); +}); + +it('keeps modern tool filtering, later-page output validation and header mirroring', async () => { + const f = await fixture(true); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + f.results.set('tools', [ + { tools: [items.tools], nextCursor: 'second', first: true }, + { + tools: [ + { + name: 'invalid-header', + inputSchema: { + type: 'object', + properties: { route: { type: 'string', 'x-mcp-header': '' } }, + }, + }, + { + name: 'later', + inputSchema: { + type: 'object', + properties: { route: { type: 'string', 'x-mcp-header': 'Route' } }, + }, + outputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + ], + last: true, + }, + ]); + try { + const result = await f.client.listAllTools(); + expect(result.tools.map((t) => t.name)).toEqual(['first', 'later']); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('invalid-header')); + await f.client.callTool('later', { route: 'west' }); + const call = f.requests.find((r) => r.method === 'tools/call')!; + expect(call.headers.get('Mcp-Name')).toBe('later'); + expect(call.headers.get('Mcp-Param-Route')).toBe('west'); + expect(call.params?._meta).not.toHaveProperty('onListPage'); + f.invalidOutput(); + await expect(f.client.callTool('later', { route: 'west' })).rejects.toThrow(/output|schema/i); + } finally { + warning.mockRestore(); + await f.client.close(); + } +}); diff --git a/test/unit/lib/utils.test.ts b/test/unit/lib/utils.test.ts index 9d63ebcb..9d2f44f7 100644 --- a/test/unit/lib/utils.test.ts +++ b/test/unit/lib/utils.test.ts @@ -28,6 +28,7 @@ import { isProcessAlive, generateRequestId, fetchAllPages, + fetchAllPagesResult, isProtocolMismatchError, redactHeaders, redactServerConfig, @@ -673,6 +674,28 @@ describe('fetchAllPages', () => { }); }); +describe('fetchAllPagesResult', () => { + it('keeps prototype-shaped fields as own data without mutating pages', async () => { + const page = JSON.parse( + '{"items":[],"__proto__":{"first":true},"_meta":{"__proto__":{"meta":true},"constructor":"data"}}' + ); + const original = JSON.stringify(page); + const result = await fetchAllPagesResult(async () => page, 'items'); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.hasOwn(result, '__proto__')).toBe(true); + expect(Object.hasOwn(result._meta, '__proto__')).toBe(true); + expect(result._meta.constructor).toBe('data'); + expect(JSON.parse(JSON.stringify(result))).toEqual(JSON.parse(original)); + expect(JSON.stringify(page)).toBe(original); + }); + + it('uses the existing cursor-cycle guard before returning an aggregate', async () => { + await expect( + fetchAllPagesResult(async () => ({ items: [], nextCursor: 'same' }), 'items') + ).rejects.toThrow(/pagination cursor/); + }); +}); + describe('isProtocolMismatchError', () => { it('matches the envelope rejection a 2026-07-28 server answers a bare modern header with', () => { // Exactly what a resumed session used to hit on every reconnect (#374).