Add JSON output support to auth and emit-ts

This commit is contained in:
Peter Steinberger 2025-11-07 04:39:50 +00:00
parent 9d8fc97db0
commit 13ed1ba307
15 changed files with 264 additions and 93 deletions

View File

@ -134,6 +134,8 @@ Helpful flags:
- `--output <format>` or `--raw` -- control formatted output (defaults to pretty-printed auto detection).
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
- `--output json/raw` (on `mcporter call`) -- when a connection fails, MCPorter prints the usual colorized hint and also emits a structured `{ server, tool, issue }` envelope so scripts can handle auth/offline/http errors programmatically.
- `--json` (on `mcporter auth`) -- emit the same structured connection envelope whenever OAuth/transport setup fails, instead of throwing an error.
- `--json` (on `mcporter emit-ts`) -- print a JSON summary describing the emitted files (mode + output paths) instead of text logs—handy when generating artifacts inside scripts.
- `--all-parameters` -- show every schema field when listing a server (default output shows at least five parameters plus a summary of the rest).
- `--http-url <https://…>` / `--stdio "command …"` -- describe an ad-hoc MCP server inline (pair with `--env KEY=value`, `--cwd`, `--name`, and `--persist <config.json>` as needed). These flags now work with `mcporter auth` too, so `mcporter auth https://mcp.example.com/mcp` just works.
- For OAuth-protected servers such as `vercel`, run `npx mcporter auth vercel` once to complete login.
@ -301,6 +303,7 @@ npx mcporter emit-ts linear --mode client --out clients/linear.ts
- `--mode types` (default) produces a `.d.ts` interface you can import anywhere.
- `--mode client` emits the `.d.ts` **and** a `.ts` helper that wraps `createRuntime` / `createServerProxy` for you.
- Add `--include-optional` whenever you want every optional field spelled out (mirrors `mcporter list --all-parameters`).
- Add `--json` to emit a structured summary (mode plus output paths) instead of plain-text logs when scripting `emit-ts`.
- The `<server>` argument also understands HTTP URLs and selectors with `.tool` suffixes or missing protocols—mirroring the main CLI.
See [docs/emit-ts.md](docs/emit-ts.md) for the full flag reference plus inline snapshots of the emitted files.

View File

@ -10,6 +10,7 @@ Two new flag sets let you describe a server on the command line:
- `mcporter call --stdio "bun run ./server.ts" --name local-tools`
You can also pass a bare URL as the selector (`mcporter list https://mcp.linear.app/mcp`) or embed the URL in a `call` expression (`mcporter call 'https://mcp.example.com/tools.generate({ topic: "release" })'`).
- Add `--json` to `mcporter list …` when you need a machine-readable summary of status counts and per-server failures, use `--output json`/`--output raw` with `mcporter call` to receive structured `{ server, tool, issue }` envelopes whenever a transport error occurs, and run `mcporter auth … --json` to capture the same envelope if OAuth or transport setup fails.
## Transport Detection

View File

@ -6,6 +6,7 @@
|-------|---------|-------|
| Flag-based (compatible) | `mcporter call linear.create_comment --issue-id LNR-123 --body "Hi"` | Use `key=value`, `key:value`, or `key: value` pairs—ideal for shell scripts. |
| Function-call (expressive) | `mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hi")'` | Mirrors the pseudo-TypeScript signature shown by `mcporter list`; unlabeled values map to schema order. |
| Structured output | `mcporter call 'linear.create_comment(...)' --output json` | Successful calls emit JSON bodies; failures emit `{ server, tool, issue }` envelopes so automation can react to auth/offline/http errors. |
Both forms share the same validation pipeline, so required parameters, enums, and formats behave identically.

View File

@ -81,6 +81,7 @@ returned objects `close()` becomes a no-op.
| `--mode types|client` | Output kind (defaults to `types`). |
| `--types-out <path>` | Optional override for the `.d.ts` file when `--mode client`. Default: derive from `--out`. |
| `--include-optional` | Include every parameter (not just the minimum 5 + required). |
| `--json` | Emit a JSON summary describing the emitted file(s) instead of plain-text logs. |
## Testing

View File

@ -7,15 +7,24 @@ You dont need `npx` every time—here are the three local entry points we use
All commands can be executed with `tsx` straight from `src/cli.ts`:
```bash
# list servers
# list servers (text)
pnpm exec tsx src/cli.ts list
# call a tool
# list servers as JSON
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
# 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
# auth flow
pnpm exec tsx src/cli.ts auth vercel
# auth flow with structured JSON status
pnpm exec tsx src/cli.ts auth vercel --json
# ad-hoc auth
pnpm exec tsx src/cli.ts auth https://mcp.supabase.com/mcp
```

View File

@ -7,14 +7,14 @@ import { handleEmitTs } from './cli/emit-ts-command.js';
import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js';
import { prepareEphemeralServerTarget } from './cli/ephemeral-target.js';
import { CliUsageError } from './cli/errors.js';
import { buildConnectionIssueEnvelope } from './cli/json-output.js';
import { extractFlags, expectValue } from './cli/flag-utils.js';
import { extractFlags } from './cli/flag-utils.js';
import { handleGenerateCli } from './cli/generate-cli-runner.js';
import { looksLikeHttpUrl } from './cli/http-utils.js';
import { handleInspectCli } from './cli/inspect-cli-command.js';
import { buildConnectionIssueEnvelope } from './cli/json-output.js';
import { handleList } from './cli/list-command.js';
import { consumeOutputFormat } from './cli/output-format.js';
import { getActiveLogger, getActiveLogLevel, logError, logInfo, logWarn, setLogLevel } from './cli/logger-context.js';
import { consumeOutputFormat } from './cli/output-format.js';
import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
import { analyzeConnectionError } from './error-classifier.js';
import { parseLogLevel } from './logging.js';

View File

@ -12,6 +12,8 @@ import {
normalizeIdentifier,
renderIdentifierResolutionMessages,
} from './identifier-helpers.js';
import { buildConnectionIssueEnvelope } from './json-output.js';
import { consumeOutputFormat } from './output-format.js';
import { type OutputFormat, printCallOutput, tailLogIfRequested } from './output-utils.js';
import { dumpActiveHandles } from './runtime-debug.js';
import { dimText, redText, yellowText } from './terminal.js';
@ -30,15 +32,14 @@ interface CallArgsParseResult {
ephemeral?: EphemeralServerSpec;
}
function isOutputFormat(value: string): value is OutputFormat {
return value === 'auto' || value === 'text' || value === 'markdown' || value === 'json' || value === 'raw';
}
export function parseCallArguments(args: string[]): CallArgsParseResult {
// Maintain backwards compatibility with legacy positional + key=value forms.
const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' };
const ephemeral = extractEphemeralServerFlags(args);
result.ephemeral = ephemeral;
result.output = consumeOutputFormat(args, {
defaultFormat: 'auto',
});
const positional: string[] = [];
let index = 0;
while (index < args.length) {
@ -98,18 +99,6 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
index += 2;
continue;
}
if (token === '--output') {
const value = args[index + 1];
if (!value) {
throw new Error('--output requires a format (auto|text|markdown|json|raw).');
}
if (!isOutputFormat(value)) {
throw new Error('--output format must be one of: auto, text, markdown, json, raw.');
}
result.output = value;
index += 2;
continue;
}
positional.push(token);
index += 1;
}
@ -299,7 +288,8 @@ export async function handleCall(
} catch (error) {
const issue = maybeReportConnectionIssue(server, tool, error);
if (parsed.output === 'json' || parsed.output === 'raw') {
emitConnectionIssueJson(server, tool, issue, error);
const payload = buildConnectionIssueEnvelope({ server, tool, error, issue });
console.log(JSON.stringify(payload, null, 2));
process.exitCode = 1;
return;
}
@ -599,43 +589,3 @@ function summarizeIssueMessage(message: string): string {
}
return `${trimmed.slice(0, 117)}`;
}
function emitConnectionIssueJson(
server: string,
tool: string,
issue: ConnectionIssue | undefined,
error: unknown
): void {
const payload = {
server,
tool,
error: formatErrorMessage(error),
issue: issue
? {
kind: issue.kind,
statusCode: issue.statusCode,
stdioExitCode: issue.stdioExitCode,
stdioSignal: issue.stdioSignal,
rawMessage: issue.rawMessage,
}
: undefined,
};
console.log(JSON.stringify(payload, null, 2));
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message ?? 'Unknown error';
}
if (typeof error === 'string') {
return error;
}
if (error === undefined || error === null) {
return 'Unknown error';
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}

View File

@ -8,6 +8,7 @@ import { readPackageMetadata } from './generate/template.js';
import type { ToolMetadata } from './generate/tools.js';
import { extractHttpServerTarget } from './http-utils.js';
import { buildToolDoc } from './list-detail-helpers.js';
import { consumeOutputFormat } from './output-format.js';
import { findServerByHttpUrl } from './server-lookup.js';
import { loadToolMetadata } from './tool-cache.js';
@ -17,6 +18,7 @@ interface EmitTsFlags {
mode: 'types' | 'client';
includeOptional: boolean;
typesOutPath?: string;
format: 'text' | 'json';
}
interface ParsedEmitTsOptions extends Required<Omit<EmitTsFlags, 'server' | 'outPath' | 'typesOutPath'>> {
@ -44,7 +46,21 @@ export async function handleEmitTs(runtime: Runtime, args: string[]): Promise<vo
if (options.mode === 'types') {
const source = renderTypesModule({ interfaceName, docs: docEntries, metadata });
await writeFile(options.outPath, source);
console.log(`Emitted TypeScript definitions for ${options.server}${options.outPath}`);
if (options.format === 'json') {
console.log(
JSON.stringify(
{
mode: 'types',
server: options.server,
outPath: options.outPath,
},
null,
2
)
);
} else {
console.log(`Emitted TypeScript definitions for ${options.server}${options.outPath}`);
}
return;
}
@ -59,18 +75,40 @@ export async function handleEmitTs(runtime: Runtime, args: string[]): Promise<vo
});
await writeFile(typesOutPath, typesSource);
await writeFile(options.outPath, clientSource);
console.log(`Emitted client + types for ${options.server}${options.outPath} / ${typesOutPath}`);
if (options.format === 'json') {
console.log(
JSON.stringify(
{
mode: 'client',
server: options.server,
clientOutPath: options.outPath,
typesOutPath,
},
null,
2
)
);
} else {
console.log(`Emitted client + types for ${options.server}${options.outPath} / ${typesOutPath}`);
}
}
function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
const flags: EmitTsFlags = {
mode: 'types',
includeOptional: false,
format: 'text',
};
const common = extractGeneratorFlags(args, { allowIncludeOptional: true });
if (common.includeOptional) {
flags.includeOptional = true;
}
flags.format = consumeOutputFormat(args, {
defaultFormat: 'text',
allowed: ['text', 'json'],
enableRawShortcut: false,
jsonShortcutFlag: '--json',
}) as EmitTsFlags['format'];
let index = 0;
while (index < args.length) {
const token = args[index];
@ -131,6 +169,7 @@ function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
mode: flags.mode,
includeOptional: flags.includeOptional,
typesOutPath: flags.typesOutPath ? path.resolve(flags.typesOutPath) : undefined,
format: flags.format,
};
}

View File

@ -2,6 +2,7 @@ import { readCliMetadata } from '../cli-metadata.js';
import { expectValue } from './flag-utils.js';
import { buildGenerateCliCommand, shellQuote } from './generate-cli-runner.js';
import { formatSourceSuffix } from './list-format.js';
import { consumeOutputFormat } from './output-format.js';
import { formatPathForDisplay } from './path-utils.js';
interface InspectFlags {
@ -49,7 +50,12 @@ export async function handleInspectCli(args: string[]): Promise<void> {
}
function parseInspectFlags(args: string[]): InspectFlags {
let format: 'text' | 'json' = 'text';
let format = consumeOutputFormat(args, {
defaultFormat: 'text',
allowed: ['text', 'json'],
enableRawShortcut: false,
jsonShortcutFlag: '--json',
}) as InspectFlags['format'];
let index = 0;
while (index < args.length) {
const token = args[index];
@ -57,11 +63,6 @@ function parseInspectFlags(args: string[]): InspectFlags {
index += 1;
continue;
}
if (token === '--json') {
format = 'json';
args.splice(index, 1);
continue;
}
if (token === '--format') {
const value = expectValue(token, args[index + 1]);
if (value !== 'json' && value !== 'text') {

60
src/cli/json-output.ts Normal file
View File

@ -0,0 +1,60 @@
import type { ConnectionIssue } from '../error-classifier.js';
export interface ConnectionIssueEnvelope {
server: string;
tool?: string;
error: string;
issue?: SerializedConnectionIssue;
}
export interface SerializedConnectionIssue {
kind: ConnectionIssue['kind'];
statusCode?: number;
stdioExitCode?: number;
stdioSignal?: string;
rawMessage?: string;
}
export function buildConnectionIssueEnvelope(params: {
server: string;
tool?: string;
error: unknown;
issue?: ConnectionIssue;
}): ConnectionIssueEnvelope {
return {
server: params.server,
tool: params.tool,
error: formatErrorMessage(params.error),
issue: serializeConnectionIssue(params.issue),
};
}
export function serializeConnectionIssue(issue?: ConnectionIssue): SerializedConnectionIssue | undefined {
if (!issue) {
return undefined;
}
return {
kind: issue.kind,
statusCode: issue.statusCode,
stdioExitCode: issue.stdioExitCode,
stdioSignal: issue.stdioSignal,
rawMessage: issue.rawMessage,
};
}
export function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message ?? 'Unknown error';
}
if (typeof error === 'string') {
return error;
}
if (error === undefined || error === null) {
return 'Unknown error';
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}

View File

@ -1,30 +1,38 @@
import ora from 'ora';
import type { ServerDefinition } from '../config.js';
import type { ConnectionIssue } from '../error-classifier.js';
import type { EphemeralServerSpec } from './adhoc-server.js';
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
import { prepareEphemeralServerTarget } from './ephemeral-target.js';
import type { ToolMetadata } from './generate/tools.js';
import { splitHttpToolSelector } from './http-utils.js';
import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from './identifier-helpers.js';
import type { SerializedConnectionIssue } from './json-output.js';
import { formatErrorMessage, serializeConnectionIssue } from './json-output.js';
import { buildToolDoc, formatExampleBlock } from './list-detail-helpers.js';
import type { ListSummaryResult, StatusCategory } from './list-format.js';
import { classifyListError, formatSourceSuffix, renderServerListRow } from './list-format.js';
import { consumeOutputFormat } from './output-format.js';
import { boldText, dimText, extraDimText, supportsSpinner, yellowText } from './terminal.js';
import { consumeTimeoutFlag, LIST_TIMEOUT_MS, withTimeout } from './timeouts.js';
import { loadToolMetadata } from './tool-cache.js';
import { formatTransportSummary } from './transport-utils.js';
export function extractListFlags(args: string[]): {
schema: boolean;
timeoutMs?: number;
requiredOnly: boolean;
ephemeral?: EphemeralServerSpec;
format: 'text' | 'json';
format: ListOutputFormat;
} {
let schema = false;
let timeoutMs: number | undefined;
let requiredOnly = true;
let format: 'text' | 'json' = 'text';
const format = consumeOutputFormat(args, {
defaultFormat: 'text',
allowed: ['text', 'json'],
enableRawShortcut: false,
jsonShortcutFlag: '--json',
}) as ListOutputFormat;
const ephemeral = extractEphemeralServerFlags(args);
let index = 0;
while (index < args.length) {
@ -47,16 +55,13 @@ export function extractListFlags(args: string[]): {
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
continue;
}
if (token === '--json') {
format = 'json';
args.splice(index, 1);
continue;
}
index += 1;
}
return { schema, timeoutMs, requiredOnly, ephemeral, format };
}
type ListOutputFormat = 'text' | 'json';
export async function handleList(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
args: string[]
@ -393,17 +398,6 @@ function printToolDetail(
};
}
function formatTransportSummary(
definition: ReturnType<Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>['getDefinition']>
): string {
if (definition.command.kind === 'http') {
const url = definition.command.url instanceof URL ? definition.command.url.href : String(definition.command.url);
return `HTTP ${url}`;
}
const rendered = [definition.command.command, ...(definition.command.args ?? [])].join(' ').trim();
return `STDIO ${rendered}`;
}
interface ListJsonServerEntry {
name: string;
status: StatusCategory;
@ -417,7 +411,7 @@ interface ListJsonServerEntry {
inputSchema?: unknown;
outputSchema?: unknown;
}>;
issue?: ConnectionIssue;
issue?: SerializedConnectionIssue;
authCommand?: string;
error?: string;
}
@ -478,9 +472,9 @@ function buildJsonListEntry(
result.server as ReturnType<Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>['getDefinition']>
),
source: result.server.source,
issue: advice.issue,
issue: serializeConnectionIssue(advice.issue),
authCommand: advice.authCommand,
error: advice.summary,
error: formatErrorMessage(result.error),
};
}

63
src/cli/output-format.ts Normal file
View File

@ -0,0 +1,63 @@
import type { OutputFormat } from './output-utils.js';
interface ConsumeOutputOptions {
defaultFormat?: OutputFormat;
allowed?: OutputFormat[];
enableRawShortcut?: boolean;
jsonShortcutFlag?: string;
}
export function consumeOutputFormat(args: string[], options: ConsumeOutputOptions = {}): OutputFormat {
const allowed = options.allowed ?? ['auto', 'text', 'markdown', 'json', 'raw'];
const defaultFormat = options.defaultFormat ?? 'auto';
const enableRawShortcut = options.enableRawShortcut !== false;
let format: OutputFormat = defaultFormat;
const isAllowed = (value: OutputFormat): boolean => allowed.includes(value);
let index = 0;
while (index < args.length) {
const token = args[index];
if (token === '--output') {
const value = args[index + 1];
if (!value) {
throw new Error("Flag '--output' requires a value.");
}
if (!isCliOutputFormat(value)) {
throw new Error('--output format must be one of: auto, text, markdown, json, raw.');
}
if (!isAllowed(value)) {
throw new Error(`--output format '${value}' is not supported for this command.`);
}
format = value;
args.splice(index, 2);
continue;
}
if (enableRawShortcut && token === '--raw') {
if (!isAllowed('raw')) {
throw new Error('--raw is not supported for this command.');
}
format = 'raw';
args.splice(index, 1);
continue;
}
if (options.jsonShortcutFlag && token === options.jsonShortcutFlag) {
if (!isAllowed('json')) {
throw new Error(`${options.jsonShortcutFlag} is not supported for this command.`);
}
format = 'json';
args.splice(index, 1);
continue;
}
index += 1;
}
if (!isAllowed(format)) {
throw new Error(`Format '${format}' is not supported for this command.`);
}
return format;
}
export function isCliOutputFormat(value: string): value is OutputFormat {
return value === 'auto' || value === 'text' || value === 'markdown' || value === 'json' || value === 'raw';
}

View File

@ -0,0 +1,10 @@
import type { ServerDefinition } from '../config.js';
export function formatTransportSummary(definition: ServerDefinition): string {
if (definition.command.kind === 'http') {
const url = definition.command.url instanceof URL ? definition.command.url.href : String(definition.command.url);
return `HTTP ${url}`;
}
const rendered = [definition.command.command, ...(definition.command.args ?? [])].join(' ').trim();
return `STDIO ${rendered}`;
}

View File

@ -66,4 +66,31 @@ describe('mcporter auth ad-hoc support', () => {
expect(listTools).toHaveBeenCalledWith('vercel', { autoAuthorize: true });
expect(registerDefinition).not.toHaveBeenCalled();
});
it('emits JSON envelopes when auth fails and --json is provided', async () => {
const { handleAuth } = await cliModulePromise;
const definition = {
name: 'linear',
command: { kind: 'http', url: new URL('https://mcp.linear.app/mcp') },
} as ServerDefinition;
const runtime = {
getDefinitions: () => [definition],
registerDefinition: vi.fn(),
listTools: vi.fn().mockRejectedValue(new Error('fetch failed: connect ECONNREFUSED 127.0.0.1:9000')),
getDefinition: () => definition,
} 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 expect(handleAuth(runtime, ['linear', '--json'])).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.server).toBe('linear');
expect(payload.issue.kind).toBe('offline');
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = undefined;
});
});

View File

@ -1,7 +1,7 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { __test as emitTsTestInternals, handleEmitTs } from '../src/cli/emit-ts-command.js';
import { renderClientModule, renderTypesModule } from '../src/cli/emit-ts-templates.js';
import { buildToolMetadata } from '../src/cli/generate/tools.js';
@ -92,4 +92,16 @@ describe('handleEmitTs', () => {
const typesSource = await fs.readFile(typesPath, 'utf8');
expect(typesSource).toContain('export interface ExampleComMcpGetComponentsTools');
});
it('emits JSON summaries when --json is provided', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'emit-ts-json-'));
const runtime = createRuntimeStub();
const typesPath = path.join(tmpDir, 'integration-tools.d.ts');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleEmitTs(runtime, ['integration', '--out', typesPath, '--mode', 'types', '--json']);
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.mode).toBe('types');
expect(payload.server).toBe('integration');
logSpy.mockRestore();
});
});