fix: harden live mcp cli paths
This commit is contained in:
parent
6012708bf3
commit
c0e251babe
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(...)`).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
18
src/cli.ts
18
src/cli.ts
@ -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'),
|
||||
|
||||
@ -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.',
|
||||
];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -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('[')) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(() => {});
|
||||
});
|
||||
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user