fix: harden live mcp cli paths

This commit is contained in:
Peter Steinberger 2026-05-09 12:23:33 +01:00
parent 6012708bf3
commit c0e251babe
No known key found for this signature in database
18 changed files with 329 additions and 41 deletions

View File

@ -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 <server>` 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

View File

@ -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

View File

@ -37,9 +37,9 @@ current task.
# Docs MCP
Use `npx mcporter call docs.resolve-library-id libraryName=<name>` 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=<task> libraryName=<name>`
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

View File

@ -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(...)`).

View File

@ -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

View File

@ -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.

View File

@ -97,11 +97,23 @@ export async function runCli(argv: string[]): Promise<void> {
// 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<void> {
}
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'),

View File

@ -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.',
];

View File

@ -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<string, unknown>,
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<string, unknown>,
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<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: string,
tool: string,
args: Record<string, unknown>,
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<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
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);

View File

@ -93,6 +93,21 @@ export async function handleEmitTs(runtime: Runtime, args: string[]): Promise<vo
}
}
export function printEmitTsHelp(): void {
console.error(
[
'Usage: mcporter emit-ts <server> --out <file> [flags]',
'',
'Flags:',
' --mode types|client Emit declarations only or client + declarations.',
' --out <path> Output .ts/.d.ts file.',
' --types-out <path> 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',

View File

@ -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:',
' <server> Use a configured server.',
' <command|url> Infer an inline stdio or HTTP server.',
' --server <name|json> Server name, HTTP URL, or JSON definition.',
' --command <value> Inline stdio command or HTTP URL.',
' --from <artifact> Regenerate from an existing generated CLI.',
'',
'Flags:',
' --output <path> 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')
);
}

View File

@ -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('[')) {

View File

@ -49,6 +49,18 @@ export async function handleInspectCli(args: string[]): Promise<void> {
}
}
export function printInspectCliHelp(): void {
console.error(
[
'Usage: mcporter inspect-cli <artifact> [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',

View File

@ -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);
}

View File

@ -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<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
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<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
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(

View File

@ -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: .+ <command> \[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(() => {});
});

View File

@ -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 }) => {

View File

@ -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();
}
});
});