From 13ed1ba3075ea5efe386b1c11d8a96a3fe773761 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 7 Nov 2025 04:39:50 +0000 Subject: [PATCH] Add JSON output support to auth and emit-ts --- README.md | 3 ++ docs/adhoc.md | 1 + docs/call-syntax.md | 1 + docs/emit-ts.md | 1 + docs/local.md | 13 +++++-- src/cli.ts | 6 ++-- src/cli/call-command.ts | 64 ++++------------------------------ src/cli/emit-ts-command.ts | 43 +++++++++++++++++++++-- src/cli/inspect-cli-command.ts | 13 +++---- src/cli/json-output.ts | 60 +++++++++++++++++++++++++++++++ src/cli/list-command.ts | 38 +++++++++----------- src/cli/output-format.ts | 63 +++++++++++++++++++++++++++++++++ src/cli/transport-utils.ts | 10 ++++++ tests/cli-auth.test.ts | 27 ++++++++++++++ tests/emit-ts.test.ts | 14 +++++++- 15 files changed, 264 insertions(+), 93 deletions(-) create mode 100644 src/cli/json-output.ts create mode 100644 src/cli/output-format.ts create mode 100644 src/cli/transport-utils.ts diff --git a/README.md b/README.md index 85cf4e9..1661bb3 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ Helpful flags: - `--output ` 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 ` / `--stdio "command …"` -- describe an ad-hoc MCP server inline (pair with `--env KEY=value`, `--cwd`, `--name`, and `--persist ` 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 `` 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. diff --git a/docs/adhoc.md b/docs/adhoc.md index 97a88b3..6bd1ded 100644 --- a/docs/adhoc.md +++ b/docs/adhoc.md @@ -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 diff --git a/docs/call-syntax.md b/docs/call-syntax.md index a4066f8..4d54cf3 100644 --- a/docs/call-syntax.md +++ b/docs/call-syntax.md @@ -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. diff --git a/docs/emit-ts.md b/docs/emit-ts.md index 2618846..57b0c3e 100644 --- a/docs/emit-ts.md +++ b/docs/emit-ts.md @@ -81,6 +81,7 @@ returned object’s `close()` becomes a no-op. | `--mode types|client` | Output kind (defaults to `types`). | | `--types-out ` | 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 diff --git a/docs/local.md b/docs/local.md index 5bee89a..11c001d 100644 --- a/docs/local.md +++ b/docs/local.md @@ -7,15 +7,24 @@ You don’t 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 ``` diff --git a/src/cli.ts b/src/cli.ts index 87f7e29..70f65c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; diff --git a/src/cli/call-command.ts b/src/cli/call-command.ts index d11ade9..fc054f5 100644 --- a/src/cli/call-command.ts +++ b/src/cli/call-command.ts @@ -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'; - } -} diff --git a/src/cli/emit-ts-command.ts b/src/cli/emit-ts-command.ts index bdebc04..fe40034 100644 --- a/src/cli/emit-ts-command.ts +++ b/src/cli/emit-ts-command.ts @@ -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> { @@ -44,7 +46,21 @@ export async function handleEmitTs(runtime: Runtime, args: string[]): Promise { } 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') { diff --git a/src/cli/json-output.ts b/src/cli/json-output.ts new file mode 100644 index 0000000..2aff9c3 --- /dev/null +++ b/src/cli/json-output.ts @@ -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'; + } +} diff --git a/src/cli/list-command.ts b/src/cli/list-command.ts index 372f299..8ff2154 100644 --- a/src/cli/list-command.ts +++ b/src/cli/list-command.ts @@ -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>, args: string[] @@ -393,17 +398,6 @@ function printToolDetail( }; } -function formatTransportSummary( - definition: ReturnType>['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>['getDefinition']> ), source: result.server.source, - issue: advice.issue, + issue: serializeConnectionIssue(advice.issue), authCommand: advice.authCommand, - error: advice.summary, + error: formatErrorMessage(result.error), }; } diff --git a/src/cli/output-format.ts b/src/cli/output-format.ts new file mode 100644 index 0000000..03649d2 --- /dev/null +++ b/src/cli/output-format.ts @@ -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'; +} diff --git a/src/cli/transport-utils.ts b/src/cli/transport-utils.ts new file mode 100644 index 0000000..dfa7249 --- /dev/null +++ b/src/cli/transport-utils.ts @@ -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}`; +} diff --git a/tests/cli-auth.test.ts b/tests/cli-auth.test.ts index 28a56d5..b4d4ce0 100644 --- a/tests/cli-auth.test.ts +++ b/tests/cli-auth.test.ts @@ -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>; + 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; + }); }); diff --git a/tests/emit-ts.test.ts b/tests/emit-ts.test.ts index 693ac7b..954d073 100644 --- a/tests/emit-ts.test.ts +++ b/tests/emit-ts.test.ts @@ -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(); + }); });