From c0e251babe7bb4a9c8395fb167770f1985e98a69 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 9 May 2026 12:23:33 +0100 Subject: [PATCH] fix: harden live mcp cli paths --- CHANGELOG.md | 6 ++ README.md | 8 +- docs/agent-skills.md | 6 +- docs/call-syntax.md | 2 +- docs/local.md | 6 +- docs/tool-calling.md | 6 +- src/cli.ts | 18 +++++ src/cli/call-argument-expression.ts | 6 +- src/cli/call-command.ts | 92 +++++++++++++++++----- src/cli/emit-ts-command.ts | 15 ++++ src/cli/generate-cli-runner.ts | 25 ++++++ src/cli/generate/template.ts | 17 +++- src/cli/inspect-cli-command.ts | 12 +++ src/cli/resource-command.ts | 16 +++- tests/cli-call-execution.test.ts | 64 +++++++++++++++ tests/cli-generate-cli.integration.test.ts | 19 +++++ tests/cli-help-shortcuts.test.ts | 6 ++ tests/cli-resource-command.test.ts | 46 +++++++++++ 18 files changed, 329 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d81ad..1c07000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ - Further reduce warm keep-alive call startup by avoiding runtime/config schema imports on CLI boot and using a narrower daemon call path for simple explicit calls. - Keep single-server `mcporter list` non-interactive by reusing cached OAuth without launching new auth flows, and clamp oversized OAuth startup errors so HTML responses do not flood stdout/stderr. - Label non-timeout `mcporter list ` failures as unavailable instead of timed out. +- Return concise/structured `mcporter resource` errors for servers that do not implement MCP resources instead of dumping SDK stack traces. +- Refresh Context7 examples for the live `resolve-library-id` and `query-docs` schemas. +- Make `generate-cli --help`, `inspect-cli --help`, and `emit-ts --help` print command help before flag parsing. +- Auto-correct near-miss tool names when a server reports an unknown tool as MCP `isError` content instead of throwing. +- Keep auto-correct diagnostics on stderr for `mcporter call --output json/raw` so stdout stays parseable. +- Make generated CLIs keep `--output json` parseable for plain text MCP results by falling back to the raw JSON envelope. ### Config diff --git a/README.md b/README.md index b9238b6..314ce8b 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ Required parameters always show; optional parameters stay hidden unless (a) ther ### Context7: fetch docs (no auth required) ```bash -npx mcporter call context7.resolve-library-id libraryName=react -npx mcporter call context7.get-library-docs context7CompatibleLibraryID=/websites/react_dev topic=hooks +npx mcporter call context7.resolve-library-id query="React hooks docs" libraryName=react +npx mcporter call context7.query-docs libraryId=/reactjs/react.dev query="useEffect cleanup" ``` ### Linear: search documentation (requires `LINEAR_API_KEY`) @@ -199,7 +199,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools ## Friendlier Tool Calls -- **Function-call syntax.** Instead of juggling `--flag value`, you can call tools as `mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'`. The parser supports nested objects/arrays, lets you omit labels when you want to rely on schema order (e.g. `mcporter 'context7.resolve-library-id("react")'`), and surfaces schema validation errors clearly. Deep dive in [docs/call-syntax.md](docs/call-syntax.md). +- **Function-call syntax.** Instead of juggling `--flag value`, you can call tools as `mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'`. The parser supports nested objects/arrays, lets you omit labels when you want to rely on schema order (e.g. `mcporter 'context7.resolve-library-id("React hooks docs", "react")'`), and surfaces schema validation errors clearly. Deep dive in [docs/call-syntax.md](docs/call-syntax.md). - **Flag shorthand still works.** Prefer CLI-style arguments? Stick with `mcporter linear.create_issue title=value team=value`, `title=value`, `title:value`, or even `title: value`—the CLI now normalizes all three forms. - **Unknown long flags fail fast.** `mcporter call server.tool --source import` now errors instead of silently turning `--source` into a positional tool argument. Use `source=import`, `--args '{"source":"import"}'`, or insert `--` before literal positional values that begin with `--`. - **Cheatsheet.** See [docs/tool-calling.md](docs/tool-calling.md) for a quick comparison of every supported call style (auto-inferred verbs, flags, function-calls, and ad-hoc URLs). @@ -260,7 +260,7 @@ const runtime = await createRuntime(); const tools = await runtime.listTools('context7'); const result = await runtime.callTool('context7', 'resolve-library-id', { - args: { libraryName: 'react' }, + args: { query: 'React hooks docs', libraryName: 'react' }, }); console.log(result); // prints JSON/text automatically because the CLI pretty-prints by default diff --git a/docs/agent-skills.md b/docs/agent-skills.md index bc9e51a..a412939 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -37,9 +37,9 @@ current task. # Docs MCP - Use `npx mcporter call docs.resolve-library-id libraryName=` to resolve - a package, then call `npx mcporter call docs.get-library-docs ...` with the - resolved ID and optional topic. + Use `npx mcporter call docs.resolve-library-id query= libraryName=` + to resolve a package, then call `npx mcporter call docs.query-docs ...` with + the resolved ID and docs query. ``` 4. For repeated or shareable workflows, generate a dedicated CLI instead of diff --git a/docs/call-syntax.md b/docs/call-syntax.md index 3103e2e..6d31bf1 100644 --- a/docs/call-syntax.md +++ b/docs/call-syntax.md @@ -45,7 +45,7 @@ Key details: ## Function-Call Syntax Details - **Named arguments preferred**: `issueId: "123"` keeps calls self-documenting. When labels are omitted, mcporter falls back to positional order defined by the tool schema. -- **Optional positional fallback**: omit labels when calling `mcporter 'context7.resolve-library-id("react")'`—arguments map to the schema order after any explicitly named parameters. +- **Optional positional fallback**: omit labels when calling `mcporter 'context7.resolve-library-id("React hooks docs", "react")'`—arguments map to the schema order after any explicitly named parameters. - **Literals supported**: strings, numbers, booleans, `null`, arrays, and nested objects. For strings containing spaces or commas, wrap the entire call in single quotes to keep the shell happy. - **Error feedback**: invalid keys, unsupported expressions, or parser failures bubble up with actionable messages (`Unsupported argument expression: Identifier`, `Unable to parse call expression: …`). - **Server selection**: You can embed the server in the expression (`linear.create_comment(...)`) or pass it separately (`--server linear create_comment(...)`). diff --git a/docs/local.md b/docs/local.md index 9ac566c..54fac72 100644 --- a/docs/local.md +++ b/docs/local.md @@ -20,10 +20,10 @@ pnpm exec tsx src/cli.ts list pnpm exec tsx src/cli.ts list --json # call a tool (auto formatted) -pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react +pnpm exec tsx src/cli.ts call context7.resolve-library-id query="React hooks docs" libraryName=react # call a tool but emit structured JSON on success/failure -pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react --output json +pnpm exec tsx src/cli.ts call context7.resolve-library-id query="React hooks docs" libraryName=react --output json # auth flow pnpm exec tsx src/cli.ts auth vercel @@ -60,7 +60,7 @@ After `pnpm add mcporter` in your project (or inside this repo), the shim binari ```bash pnpm mcporter:list -pnpm mcporter:call context7.get-library-docs topic=hooks +pnpm mcporter:call context7.query-docs libraryId=/reactjs/react.dev query=hooks ``` ## Debug flags recap diff --git a/docs/tool-calling.md b/docs/tool-calling.md index a476275..52940b5 100644 --- a/docs/tool-calling.md +++ b/docs/tool-calling.md @@ -37,12 +37,12 @@ mcporter call context7.resolve-library-id libraryName: value ```bash mcporter call 'linear.create_issue(title: "Bug", team: "ENG")' -mcporter 'context7.resolve-library-id(libraryName: "react")' -mcporter 'context7.resolve-library-id("react")' +mcporter 'context7.resolve-library-id(query: "React hooks docs", libraryName: "react")' +mcporter 'context7.resolve-library-id("React hooks docs", "react")' ``` - Mirrors the pseudo-TypeScript signature printed by `mcporter list`. -- You may omit labels and rely on the schema order—`mcporter 'context7.resolve-library-id("react")'` maps the first argument to `libraryName` automatically. +- You may omit labels and rely on the schema order—`mcporter 'context7.resolve-library-id("React hooks docs", "react")'` maps arguments to the live schema order automatically. - Supports nested objects/arrays and gives detailed parser errors when the expression is malformed. - Wrap the whole expression in quotes so the shell leaves parentheses/commas intact. diff --git a/src/cli.ts b/src/cli.ts index 02c8644..e63e903 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,11 +97,23 @@ export async function runCli(argv: string[]): Promise { // Early-exit command handlers that don't require runtime inference. if (command === 'generate-cli') { + if (consumeHelpTokens(args)) { + const { printGenerateCliHelp } = await import('./cli/generate-cli-runner.js'); + printGenerateCliHelp(); + process.exitCode = 0; + return; + } const { handleGenerateCli: importedHandleGenerateCli } = await import('./cli/generate-cli-runner.js'); await importedHandleGenerateCli(args, globalFlags); return; } if (command === 'inspect-cli') { + if (consumeHelpTokens(args)) { + const { printInspectCliHelp } = await import('./cli/inspect-cli-command.js'); + printInspectCliHelp(); + process.exitCode = 0; + return; + } const { handleInspectCli: importedHandleInspectCli } = await import('./cli/inspect-cli-command.js'); await importedHandleInspectCli(args); return; @@ -140,6 +152,12 @@ export async function runCli(argv: string[]): Promise { } if (command === 'emit-ts') { + if (consumeHelpTokens(args)) { + const { printEmitTsHelp } = await import('./cli/emit-ts-command.js'); + printEmitTsHelp(); + process.exitCode = 0; + return; + } const [{ createRuntime }, { handleEmitTs }] = await Promise.all([ import('./runtime.js'), import('./cli/emit-ts-command.js'), diff --git a/src/cli/call-argument-expression.ts b/src/cli/call-argument-expression.ts index b4a914f..077d329 100644 --- a/src/cli/call-argument-expression.ts +++ b/src/cli/call-argument-expression.ts @@ -51,9 +51,9 @@ function buildCallExpressionUsageError(error: unknown): CliUsageError { `Reason: ${reason}`, '', 'Examples:', - ' mcporter \'context7.resolve-library-id(libraryName: "react")\'', - ' mcporter \'context7.resolve-library-id("react")\'', - ' mcporter context7.resolve-library-id libraryName=react', + ' mcporter \'context7.resolve-library-id(query: "React hooks docs", libraryName: "react")\'', + ' mcporter \'context7.resolve-library-id("React hooks docs", "react")\'', + ' mcporter context7.resolve-library-id query="React hooks docs" libraryName=react', '', 'Tip: wrap the entire expression in single quotes so the shell preserves parentheses and commas.', ]; diff --git a/src/cli/call-command.ts b/src/cli/call-command.ts index 3108362..ccebeba 100644 --- a/src/cli/call-command.ts +++ b/src/cli/call-command.ts @@ -164,7 +164,8 @@ async function invokePreparedCall( prepared.server, prepared.tool, prepared.hydratedArgs, - prepared.timeoutMs + prepared.timeoutMs, + prepared.parsed.output ); } catch (error) { const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error); @@ -454,10 +455,11 @@ async function invokeWithAutoCorrection( server: string, tool: string, args: Record, - timeoutMs: number + timeoutMs: number, + outputFormat: OutputFormat ): Promise<{ result: unknown; resolvedTool: string }> { // Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match. - return attemptCall(runtime, server, tool, args, timeoutMs, true); + return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true); } async function attemptCall( @@ -466,10 +468,20 @@ async function attemptCall( tool: string, args: Record, timeoutMs: number, + outputFormat: OutputFormat, allowCorrection: boolean ): Promise<{ result: unknown; resolvedTool: string }> { try { const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs); + if (allowCorrection && isErrorCallResult(result)) { + const resolution = await maybeResolveToolName(runtime, server, tool, result); + if (resolution) { + const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution); + if (retry) { + return retry; + } + } + } return { result, resolvedTool: tool }; } catch (error) { if (error instanceof Error && error.message === 'Timeout') { @@ -491,25 +503,42 @@ async function attemptCall( throw error; } - const messages = renderIdentifierResolutionMessages({ - entity: 'tool', - attempted: tool, - resolution, - scope: server, - }); - if (resolution.kind === 'suggest') { - if (messages.suggest) { - console.error(dimText(messages.suggest)); - } + const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution); + if (!retry) { throw error; } - if (messages.auto) { - console.log(dimText(messages.auto)); - } - return attemptCall(runtime, server, resolution.value, args, timeoutMs, false); + return retry; } } +async function maybeRetryResolvedTool( + runtime: Awaited>, + server: string, + tool: string, + args: Record, + timeoutMs: number, + outputFormat: OutputFormat, + resolution: ToolResolution +): Promise<{ result: unknown; resolvedTool: string } | undefined> { + const messages = renderIdentifierResolutionMessages({ + entity: 'tool', + attempted: tool, + resolution, + scope: server, + }); + if (resolution.kind === 'suggest') { + if (messages.suggest) { + console.error(dimText(messages.suggest)); + } + return undefined; + } + if (messages.auto) { + const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log; + emitAutoMessage(dimText(messages.auto)); + } + return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false); +} + async function maybeResolveToolName( runtime: Awaited>, server: string, @@ -542,14 +571,39 @@ async function maybeResolveToolName( } function extractMissingToolFromError(error: unknown): string | undefined { - const message = error instanceof Error ? error.message : typeof error === 'string' ? error : undefined; + const message = extractErrorMessageText(error); if (!message) { return undefined; } - const match = message.match(/Tool\s+([A-Za-z0-9._-]+)\s+not found/i); + const match = + message.match(/Tool\s+([A-Za-z0-9._-]+)\s+not found/i) ?? message.match(/Unknown tool:?\s+([A-Za-z0-9._-]+)/i); return match?.[1]; } +function extractErrorMessageText(value: unknown): string | undefined { + if (value instanceof Error) { + return value.message; + } + if (typeof value === 'string') { + return value; + } + if (!value || typeof value !== 'object') { + return undefined; + } + const content = (value as { content?: unknown }).content; + if (!Array.isArray(content)) { + return undefined; + } + return content + .map((entry) => + entry && typeof entry === 'object' && typeof (entry as { text?: unknown }).text === 'string' + ? (entry as { text: string }).text + : '' + ) + .filter(Boolean) + .join('\n'); +} + function maybeReportConnectionIssue(server: string, tool: string, error: unknown): ConnectionIssue | undefined { const issue = analyzeConnectionError(error); const detail = summarizeIssueMessage(issue.rawMessage); diff --git a/src/cli/emit-ts-command.ts b/src/cli/emit-ts-command.ts index 7dafb7b..ad25861 100644 --- a/src/cli/emit-ts-command.ts +++ b/src/cli/emit-ts-command.ts @@ -93,6 +93,21 @@ export async function handleEmitTs(runtime: Runtime, args: string[]): Promise --out [flags]', + '', + 'Flags:', + ' --mode types|client Emit declarations only or client + declarations.', + ' --out Output .ts/.d.ts file.', + ' --types-out Declaration output path for --mode client.', + ' --include-optional Include optional schema fields in signatures.', + ' --json Print a JSON summary.', + ].join('\n') + ); +} + function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions { const flags: EmitTsFlags = { mode: 'types', diff --git a/src/cli/generate-cli-runner.ts b/src/cli/generate-cli-runner.ts index 69ec585..d6eecc9 100644 --- a/src/cli/generate-cli-runner.ts +++ b/src/cli/generate-cli-runner.ts @@ -80,3 +80,28 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P excludeTools: parsed.excludeTools, }); } + +export function printGenerateCliHelp(): void { + console.error( + [ + 'Usage: mcporter generate-cli [server | command | url] [flags]', + '', + 'Targets:', + ' Use a configured server.', + ' Infer an inline stdio or HTTP server.', + ' --server Server name, HTTP URL, or JSON definition.', + ' --command Inline stdio command or HTTP URL.', + ' --from Regenerate from an existing generated CLI.', + '', + 'Flags:', + ' --output Write the TypeScript template to a path.', + ' --bundle [path] Emit a bundled JavaScript artifact.', + ' --compile [path] Emit a Bun-compiled binary.', + ' --runtime node|bun Runtime for generated code.', + ' --bundler rolldown|bun Bundler for JavaScript output.', + ' --include-tools a,b Generate only these tools.', + ' --exclude-tools a,b Omit these tools.', + ' --dry-run Print regeneration command for --from.', + ].join('\n') + ); +} diff --git a/src/cli/generate/template.ts b/src/cli/generate/template.ts index c2e2ceb..ea7a6c2 100644 --- a/src/cli/generate/template.ts +++ b/src/cli/generate/template.ts @@ -178,14 +178,16 @@ ${renderEmbeddedHelpSource()} function printResult(result: unknown, format: string) { \tconst wrapped = createCallResult(result); +\tconst rawPayload = unwrapRawPayload(wrapped.raw); \tswitch (format) { \t\tcase 'json': { \t\t\tconst json = wrapped.json(); -\t\t\tif (json) { +\t\t\tif (json !== null) { \t\t\t\tconsole.log(JSON.stringify(json, null, 2)); \t\t\t\treturn; \t\t\t} -\t\t\tbreak; +\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2)); +\t\t\treturn; \t\t} \t\tcase 'markdown': { \t\t\tconst markdown = wrapped.markdown(); @@ -196,7 +198,7 @@ function printResult(result: unknown, format: string) { \t\t\tbreak; \t\t} \t\tcase 'raw': { -\t\t\tconsole.log(JSON.stringify(wrapped.raw, null, 2)); +\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2)); \t\t\treturn; \t\t} \t} @@ -204,10 +206,17 @@ function printResult(result: unknown, format: string) { \tif (text) { \t\tconsole.log(text); \t} else { -\t\tconsole.log(JSON.stringify(wrapped.raw, null, 2)); +\t\tconsole.log(JSON.stringify(rawPayload, null, 2)); \t} } +function unwrapRawPayload(value: unknown): unknown { +\tif (value && typeof value === 'object' && 'raw' in value) { +\t\treturn (value as { raw: unknown }).raw; +\t} +\treturn value; +} + function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolean' | 'json') { \tconst trimmed = value.trim(); \tif (trimmed.startsWith('[')) { diff --git a/src/cli/inspect-cli-command.ts b/src/cli/inspect-cli-command.ts index 4ade29b..b6ce5fc 100644 --- a/src/cli/inspect-cli-command.ts +++ b/src/cli/inspect-cli-command.ts @@ -49,6 +49,18 @@ export async function handleInspectCli(args: string[]): Promise { } } +export function printInspectCliHelp(): void { + console.error( + [ + 'Usage: mcporter inspect-cli [flags]', + '', + 'Flags:', + ' --json Print embedded metadata as JSON.', + ' --format text|json Choose output format.', + ].join('\n') + ); +} + function parseInspectFlags(args: string[]): InspectFlags { let format = consumeOutputFormat(args, { defaultFormat: 'text', diff --git a/src/cli/resource-command.ts b/src/cli/resource-command.ts index 27d3a62..ba494d6 100644 --- a/src/cli/resource-command.ts +++ b/src/cli/resource-command.ts @@ -1,4 +1,6 @@ +import { analyzeConnectionError } from '../error-classifier.js'; import { wrapCallResult } from '../result-utils.js'; +import { buildConnectionIssueEnvelope, formatErrorMessage } from './json-output.js'; import { consumeOutputFormat } from './output-format.js'; import { printCallOutput } from './output-utils.js'; @@ -20,7 +22,19 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise< throw new Error(`Unexpected resource arguments: ${args.join(' ')}`); } - const result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server); + let result: unknown; + try { + result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server); + } catch (error) { + const issue = analyzeConnectionError(error); + if (output === 'json' || output === 'raw') { + console.log(JSON.stringify(buildConnectionIssueEnvelope({ server, error, issue }), null, 2)); + } else { + console.error(`[mcporter] ${formatErrorMessage(error)}`); + } + process.exitCode = 1; + return; + } const { callResult } = wrapCallResult(result); printCallOutput(callResult, result, output); } diff --git a/tests/cli-call-execution.test.ts b/tests/cli-call-execution.test.ts index 3b744a3..5c867d9 100644 --- a/tests/cli-call-execution.test.ts +++ b/tests/cli-call-execution.test.ts @@ -165,6 +165,70 @@ describe('CLI call execution behavior', () => { } }); + it('auto-corrects near-miss tool names returned as MCP isError content', async () => { + const { handleCall } = await cliModulePromise; + const callTool = vi + .fn() + .mockResolvedValueOnce({ content: [{ type: 'text', text: 'Unknown tool: read_wiki_structur' }], isError: true }) + .mockResolvedValueOnce({ ok: true }); + const listTools = vi.fn().mockResolvedValue([{ name: 'read_wiki_structure' }]); + const runtime = { + callTool, + listTools, + close: vi.fn().mockResolvedValue(undefined), + } as unknown as Awaited>; + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleCall(runtime, ['deepwiki.read_wiki_structur']); + + const notes = logSpy.mock.calls.map((call) => call.join(' ')); + expect(notes.some((line) => line.includes('Auto-corrected tool call to deepwiki.read_wiki_structure'))).toBe(true); + expect(callTool).toHaveBeenCalledTimes(2); + expect(callTool).toHaveBeenNthCalledWith( + 1, + 'deepwiki', + 'read_wiki_structur', + expect.objectContaining({ args: {} }) + ); + expect(callTool).toHaveBeenNthCalledWith( + 2, + 'deepwiki', + 'read_wiki_structure', + expect.objectContaining({ args: {} }) + ); + + logSpy.mockRestore(); + }); + + it('keeps auto-correct diagnostics off stdout for JSON output', async () => { + const { handleCall } = await cliModulePromise; + const callTool = vi + .fn() + .mockResolvedValueOnce({ content: [{ type: 'text', text: 'Unknown tool: read_wiki_structur' }], isError: true }) + .mockResolvedValueOnce({ content: [{ type: 'text', text: '{"ok":true}' }] }); + const listTools = vi.fn().mockResolvedValue([{ name: 'read_wiki_structure' }]); + const runtime = { + callTool, + listTools, + close: vi.fn().mockResolvedValue(undefined), + } as unknown as Awaited>; + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCall(runtime, ['deepwiki.read_wiki_structur', '--output', 'json']); + + expect(errorSpy.mock.calls.map((call) => call.join(' ')).join('\n')).toContain( + 'Auto-corrected tool call to deepwiki.read_wiki_structure' + ); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(logSpy.mock.calls[0]?.[0]?.toString() ?? '{}')).toEqual({ ok: true }); + + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + it('still requires an explicit tool when multiple are available', async () => { const { handleCall } = await cliModulePromise; const { runtime, callTool } = createRuntimeStub( diff --git a/tests/cli-generate-cli.integration.test.ts b/tests/cli-generate-cli.integration.test.ts index dd95a7a..d6bf5fd 100644 --- a/tests/cli-generate-cli.integration.test.ts +++ b/tests/cli-generate-cli.integration.test.ts @@ -117,6 +117,17 @@ describe('mcporter CLI integration', () => { structuredContent: { ok: true }, }) ); + server.registerTool( + 'plain_text', + { + title: 'Plain Text', + description: 'Returns non-JSON text', + inputSchema: { value: z.string().optional() }, + }, + async ({ value }) => ({ + content: [{ type: 'text', text: value ?? 'plain' }], + }) + ); app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); @@ -192,6 +203,14 @@ describe('mcporter CLI integration', () => { }); expect(helpOutput.stdout).toMatch(/Usage: .+ \[options]/); expect(helpOutput.stdout).toContain('Context7 integration harness'); + + const plainOutput = await runGeneratedCli(bundlePath, ['plain-text', '--value', 'hello', '--output', 'json'], { + ...process.env, + MCPORTER_NO_FORCE_EXIT: '1', + }); + const plainJson = JSON.parse(plainOutput.stdout) as { content?: Array<{ text?: string }> }; + expect(plainJson.content?.[0]?.text).toBe('hello'); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); }); diff --git a/tests/cli-help-shortcuts.test.ts b/tests/cli-help-shortcuts.test.ts index dc25767..bc54c81 100644 --- a/tests/cli-help-shortcuts.test.ts +++ b/tests/cli-help-shortcuts.test.ts @@ -28,6 +28,12 @@ describe('mcporter help shortcuts (hidden)', () => { { args: ['auth', 'help'], expectSnippet: 'Usage: mcporter auth' }, { args: ['list', '--help'], expectSnippet: 'Usage: mcporter list' }, { args: ['list', 'help'], expectSnippet: 'Usage: mcporter list' }, + { args: ['generate-cli', '--help'], expectSnippet: 'Usage: mcporter generate-cli' }, + { args: ['generate-cli', 'help'], expectSnippet: 'Usage: mcporter generate-cli' }, + { args: ['inspect-cli', '--help'], expectSnippet: 'Usage: mcporter inspect-cli' }, + { args: ['inspect-cli', 'help'], expectSnippet: 'Usage: mcporter inspect-cli' }, + { args: ['emit-ts', '--help'], expectSnippet: 'Usage: mcporter emit-ts' }, + { args: ['emit-ts', 'help'], expectSnippet: 'Usage: mcporter emit-ts' }, ]; it.each(cases)('prints help for %j', async ({ args, expectSnippet }) => { diff --git a/tests/cli-resource-command.test.ts b/tests/cli-resource-command.test.ts index a0cb83d..489e02f 100644 --- a/tests/cli-resource-command.test.ts +++ b/tests/cli-resource-command.test.ts @@ -52,4 +52,50 @@ describe('handleResource', () => { logSpy.mockRestore(); } }); + + it('prints structured JSON for resource listing failures', async () => { + const runtime = createRuntime(); + runtime.listResources.mockRejectedValue(new Error('MCP error -32601: Method not found')); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + await handleResource(runtime, ['docs', '--output', 'json']); + const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}'); + expect(payload).toMatchObject({ + server: 'docs', + error: 'MCP error -32601: Method not found', + issue: { + kind: 'other', + rawMessage: 'MCP error -32601: Method not found', + }, + }); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it('prints a concise error for text resource listing failures', async () => { + const runtime = createRuntime(); + runtime.listResources.mockRejectedValue(new Error('MCP error -32601: Method not found')); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + await handleResource(runtime, ['docs']); + expect(errorSpy).toHaveBeenCalledWith('[mcporter] MCP error -32601: Method not found'); + expect(logSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); });