From af38e3be40392cc7b82f18d9fa72dfed3227136b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Nov 2025 19:15:28 +0100 Subject: [PATCH] refactor(cli): split generate-cli runner --- docs/refactor.md | 11 + src/cli/generate-cli-runner.ts | 486 +------------------------- src/cli/generate/flags.ts | 201 +++++++++++ src/cli/generate/name-utils.ts | 112 ++++++ src/cli/generate/output.ts | 27 ++ src/cli/generate/server-utils.ts | 31 ++ src/cli/generate/template-data.ts | 103 ++++++ src/cli/generate/types.ts | 1 + src/cli/inspect-cli-command.ts | 2 +- src/runtime.ts | 359 +------------------ src/runtime/errors.ts | 17 + src/runtime/oauth.ts | 112 ++++++ src/runtime/transport.ts | 168 +++++++++ src/runtime/utils.ts | 54 +++ tests/cli-generate-runner.test.ts | 40 ++- tests/cli-oauth-timeout-flag.test.ts | 2 +- tests/runtime-oauth-detection.test.ts | 12 +- tests/runtime-oauth-timeout.test.ts | 8 +- tests/runtime-oauth-utils.test.ts | 14 + tests/runtime-transport.test.ts | 68 ++++ tests/runtime-utils.test.ts | 31 ++ tests/runtime.test.ts | 6 +- 22 files changed, 1005 insertions(+), 860 deletions(-) create mode 100644 src/cli/generate/flags.ts create mode 100644 src/cli/generate/name-utils.ts create mode 100644 src/cli/generate/output.ts create mode 100644 src/cli/generate/server-utils.ts create mode 100644 src/cli/generate/template-data.ts create mode 100644 src/cli/generate/types.ts create mode 100644 src/runtime/errors.ts create mode 100644 src/runtime/oauth.ts create mode 100644 src/runtime/transport.ts create mode 100644 src/runtime/utils.ts create mode 100644 tests/runtime-oauth-utils.test.ts create mode 100644 tests/runtime-transport.test.ts create mode 100644 tests/runtime-utils.test.ts diff --git a/docs/refactor.md b/docs/refactor.md index 9b9ac81..3da69b0 100644 --- a/docs/refactor.md +++ b/docs/refactor.md @@ -60,6 +60,17 @@ Each section lists the goal, why it matters, and the concrete steps/tests needed - **Next**: Once the other doc changes land, update README/spec to link to the reference and drop redundant sections. +## 6. Runtime Module Split *(Completed)* +- **Problem**: `src/runtime.ts` had grown bulky (600+ lines) mixing transport setup, OAuth flow control, and small helpers, making tests and reuse harder. +- **What we did**: + 1. Extracted transport construction/retry logic to `src/runtime/transport.ts`. + 2. Moved OAuth helpers (timeouts, connect retry, errors) to `src/runtime/oauth.ts` and centralized env-parsed timeouts. + 3. Pulled argument/timeout utilities into `src/runtime/utils.ts`. + 4. Made reset-policy logic reusable via `src/runtime/errors.ts`. + 5. Switched tests to import helpers directly instead of using `runtime.__test`. + 6. Added a targeted transport test to cover SSE fallback and OAuth promotion. +- **Next**: Keep new helpers in sync as runtime evolves; prefer adding surface to these modules over growing `runtime.ts` again. + --- Tracking the above here keeps future agents aligned. Update this checklist as items ship (mark sections “Completed” when done, or delete the doc once empty). diff --git a/src/cli/generate-cli-runner.ts b/src/cli/generate-cli-runner.ts index 4ec52ec..216d1a2 100644 --- a/src/cli/generate-cli-runner.ts +++ b/src/cli/generate-cli-runner.ts @@ -1,28 +1,10 @@ -import type { CliArtifactMetadata, SerializedServerDefinition } from '../cli-metadata.js'; import { readCliMetadata } from '../cli-metadata.js'; -import type { GenerateCliOptions } from '../generate-cli.js'; -import { generateCli } from '../generate-cli.js'; -import { splitCommandLine } from './adhoc-server.js'; import type { FlagMap } from './flag-utils.js'; -import { expectValue } from './flag-utils.js'; -import { extractGeneratorFlags } from './generate/flag-parser.js'; -import { extractHttpServerTarget, looksLikeHttpUrl, normalizeHttpUrlCandidate } from './http-utils.js'; - -export interface GenerateFlags { - server?: string; - name?: string; - command?: CommandInput; - description?: string; - output?: string; - bundler?: 'rolldown' | 'bun'; - bundle?: boolean | string; - compile?: boolean | string; - runtime?: 'node' | 'bun'; - timeout: number; - minify?: boolean; - from?: string; - dryRun: boolean; -} +import { parseGenerateFlags } from './generate/flags.js'; +import { inferNameFromCommand } from './generate/name-utils.js'; +import { performGenerateFromArtifact, performGenerateFromRequest } from './generate/output.js'; +import { buildInlineServerDefinition } from './generate/server-utils.js'; +import { buildGenerateCliCommand, resolveGenerateRequestFromArtifact } from './generate/template-data.js'; // handleGenerateCli parses flags and generates the requested standalone CLI. export async function handleGenerateCli(args: string[], globalFlags: FlagMap): Promise { @@ -34,25 +16,9 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P throw new Error('--dry-run currently requires --from .'); } - if (!parsed.server && !parsed.command && !parsed.from) { - const positional = args.find((token) => token && !token.startsWith('--')); - if (positional) { - const position = args.indexOf(positional); - if (position !== -1) { - args.splice(position, 1); - } - if (looksLikeInlineCommand(positional)) { - parsed.command = normalizeCommandInput(positional); - } else if (looksLikeHttpUrl(positional) || positional.includes('://')) { - parsed.command = positional; - } else { - parsed.server = positional; - } - } - } - if (parsed.from) { - const { metadata, request } = await resolveGenerateRequestFromArtifact(parsed, globalFlags); + const metadata = await readCliMetadata(parsed.from); + const request = resolveGenerateRequestFromArtifact(parsed, metadata, globalFlags); if (parsed.dryRun) { const command = buildGenerateCliCommand( { @@ -73,14 +39,7 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P console.log(` ${command}`); return; } - const { outputPath, bundlePath, compilePath } = await generateCli(request); - if (metadata.artifact.kind === 'binary' && compilePath) { - console.log(`Regenerated compiled CLI at ${compilePath}`); - } else if (metadata.artifact.kind === 'bundle' && bundlePath) { - console.log(`Regenerated bundled CLI at ${bundlePath}`); - } else { - console.log(`Regenerated template at ${outputPath}`); - } + await performGenerateFromArtifact(metadata, request); return; } @@ -95,7 +54,7 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P 'Provide --server with a definition or a command we can infer a name from (use --name to override).' ); } - const { outputPath, bundlePath, compilePath } = await generateCli({ + await performGenerateFromRequest({ serverRef, configPath: globalFlags['--config'], rootDir: globalFlags['--root'], @@ -107,431 +66,4 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P compile: parsed.compile, minify: parsed.minify ?? false, }); - console.log(`Generated CLI at ${outputPath}`); - if (bundlePath) { - console.log(`Bundled executable created at ${bundlePath}`); - } - if (compilePath) { - console.log(`Compiled executable created at ${compilePath}`); - } } - -export async function resolveGenerateRequestFromArtifact( - parsed: GenerateFlags, - globalFlags: FlagMap -): Promise<{ metadata: CliArtifactMetadata; request: GenerateCliOptions }> { - if (!parsed.from) { - throw new Error('Missing --from artifact path.'); - } - const metadata = await readCliMetadata(parsed.from); - const invocation = { ...metadata.invocation }; - const serverRef = - parsed.server ?? invocation.serverRef ?? metadata.server.name ?? JSON.stringify(metadata.server.definition); - if (!serverRef) { - throw new Error('Unable to determine server definition from artifact; pass --server with a target name.'); - } - return { - metadata, - request: { - serverRef, - configPath: globalFlags['--config'] ?? invocation.configPath, - rootDir: globalFlags['--root'] ?? invocation.rootDir, - outputPath: parsed.output ?? invocation.outputPath, - runtime: parsed.runtime ?? invocation.runtime, - bundler: parsed.bundler ?? invocation.bundler, - bundle: parsed.bundle ?? invocation.bundle, - timeoutMs: parsed.timeout ?? invocation.timeoutMs, - compile: parsed.compile ?? invocation.compile, - minify: parsed.minify ?? invocation.minify ?? false, - }, - }; -} - -type InvocationSnapshot = CliArtifactMetadata['invocation']; - -interface InspectableInvocation extends InvocationSnapshot { - serverRef?: string; -} - -export function buildGenerateCliCommand( - invocation: InspectableInvocation, - definition: SerializedServerDefinition, - globalFlags: FlagMap = {} -): string { - const tokens: string[] = ['mcporter']; - const configPath = invocation.configPath ?? globalFlags['--config']; - const rootDir = invocation.rootDir ?? globalFlags['--root']; - if (configPath) { - tokens.push('--config', configPath); - } - if (rootDir) { - tokens.push('--root', rootDir); - } - tokens.push('generate-cli'); - - const serverRef = invocation.serverRef ?? definition.name ?? JSON.stringify(definition); - tokens.push('--server', serverRef); - - if (invocation.outputPath) { - tokens.push('--output', invocation.outputPath); - } - if (invocation.bundler && invocation.bundler !== 'rolldown') { - tokens.push('--bundler', invocation.bundler); - } - if (typeof invocation.bundle === 'string') { - tokens.push('--bundle', invocation.bundle); - } else if (invocation.bundle) { - tokens.push('--bundle'); - } - if (typeof invocation.compile === 'string') { - tokens.push('--compile', invocation.compile); - } else if (invocation.compile) { - tokens.push('--compile'); - } - if (invocation.runtime) { - tokens.push('--runtime', invocation.runtime); - } - if (invocation.timeoutMs && invocation.timeoutMs !== 30_000) { - tokens.push('--timeout', String(invocation.timeoutMs)); - } - if (invocation.minify) { - tokens.push('--minify'); - } - return tokens.map(shellQuote).join(' '); -} - -export function shellQuote(value: string): string { - if (/^[A-Za-z0-9_./@%-]+$/.test(value)) { - return value; - } - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function parseGenerateFlags(args: string[]): GenerateFlags { - const common = extractGeneratorFlags(args); - let server: string | undefined; - let name: string | undefined; - let command: CommandInput | undefined; - let description: string | undefined; - let output: string | undefined; - let bundler: 'rolldown' | 'bun' | undefined; - let bundle: boolean | string | undefined; - let compile: boolean | string | undefined; - const runtime: 'node' | 'bun' | undefined = common.runtime; - const timeout = common.timeout ?? 30_000; - let minify: boolean | undefined; - let from: string | undefined; - let dryRun = false; - - let index = 0; - while (index < args.length) { - const token = args[index]; - if (!token) { - index += 1; - continue; - } - if (token === '--from') { - from = expectValue(token, args[index + 1]); - args.splice(index, 2); - continue; - } - if (token === '--dry-run') { - dryRun = true; - args.splice(index, 1); - continue; - } - if (token === '--server') { - server = expectValue(token, args[index + 1]); - args.splice(index, 2); - continue; - } - if (token === '--name') { - name = expectValue(token, args[index + 1]); - args.splice(index, 2); - continue; - } - if (token === '--command') { - const value = expectValue(token, args[index + 1]); - command = normalizeCommandInput(value); - args.splice(index, 2); - continue; - } - if (token === '--description') { - description = expectValue(token, args[index + 1]); - args.splice(index, 2); - continue; - } - if (token === '--output') { - output = expectValue(token, args[index + 1]); - args.splice(index, 2); - continue; - } - if (token === '--bundle') { - const next = args[index + 1]; - if (!next || next.startsWith('--')) { - bundle = true; - args.splice(index, 1); - } else { - bundle = next; - args.splice(index, 2); - } - continue; - } - if (token === '--bundler') { - const value = expectValue(token, args[index + 1]); - if (value !== 'rolldown' && value !== 'bun') { - throw new Error("--bundler must be 'rolldown' or 'bun'."); - } - bundler = value; - args.splice(index, 2); - continue; - } - if (token === '--compile') { - const next = args[index + 1]; - if (!next || next.startsWith('--')) { - compile = true; - args.splice(index, 1); - } else { - compile = next; - args.splice(index, 2); - } - continue; - } - if (token === '--minify') { - minify = true; - args.splice(index, 1); - continue; - } - if (token === '--no-minify') { - minify = false; - args.splice(index, 1); - continue; - } - if (token.startsWith('--')) { - throw new Error(`Unknown flag '${token}' for generate-cli.`); - } - index += 1; - } - - if (!server && !command && !from) { - const positional = args.find((token) => token && !token.startsWith('--')); - if (positional) { - const position = args.indexOf(positional); - if (position !== -1) { - args.splice(position, 1); - } - if (looksLikeInlineCommand(positional)) { - command = normalizeCommandInput(positional); - } else if (looksLikeHttpUrl(positional) || positional.includes('://')) { - command = positional; - } else { - server = positional; - } - } - } - - return { - server, - name, - command, - description, - output, - bundler, - bundle, - compile, - runtime, - timeout, - minify, - from, - dryRun, - }; -} - -type CommandInput = string | InlineCommandSpec; - -interface InlineCommandSpec { - command: string; - args?: string[]; -} - -function buildInlineServerDefinition( - name: string, - command: CommandInput, - description?: string -): Record { - const base: Record = { name }; - if (description) { - base.description = description; - } - if (typeof command === 'string') { - base.command = command; - return base; - } - base.command = command.command; - if (command.args && command.args.length > 0) { - base.args = command.args; - } - return base; -} - -function inferNameFromCommand(command: CommandInput): string | undefined { - if (typeof command === 'string') { - const trimmed = command.trim(); - if (!trimmed) { - return undefined; - } - const candidate = normalizeHttpUrlCandidate(trimmed) ?? trimmed; - try { - const url = new URL(candidate); - const derived = deriveNameFromUrl(url); - if (derived) { - return derived; - } - } catch { - // not a URL; fall through to filesystem heuristics - } - if (looksLikeInlineCommand(trimmed)) { - try { - const parsed = parseInlineCommand(trimmed); - const derived = inferNameFromCommand(parsed); - if (derived) { - return derived; - } - } catch { - // unable to parse; fall through to token heuristic - } - } - const firstToken = trimmed.split(/\s+/)[0] ?? trimmed; - const candidateToken = firstToken.split(/[\\/]/).pop() ?? firstToken; - return slugify(candidateToken.replace(/\.[a-z0-9]+$/i, '')); - } - const parts = [command.command, ...(command.args ?? [])]; - if (parts.length === 0) { - return undefined; - } - const script = parts.find((part) => /\.[cm]?(ts|js)x?$/i.test(part)); - if (script) { - return slugify(stripExtension(basename(script))); - } - const packageArg = parts.find((_part, index) => index > 0 && /[@/]/.test(_part)); - if (packageArg) { - return slugify(packageArg.replace(/^@/, '').split('@')[0] ?? packageArg); - } - const bareArg = findLastPositionalArg(parts); - if (bareArg) { - return slugify(bareArg); - } - return slugify(basename(parts[0] ?? 'command')); -} - -function slugify(value: string): string | undefined { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalized || undefined; -} - -function basename(value: string): string { - const segments = value.split(/[\\/]/); - return segments[segments.length - 1] ?? value; -} - -function stripExtension(value: string): string { - const index = value.lastIndexOf('.'); - if (index === -1) { - return value; - } - return value.slice(0, index); -} - -function findLastPositionalArg(parts: string[]): string | undefined { - for (let index = parts.length - 1; index >= 1; index -= 1) { - const part = parts[index]; - if (!part) { - continue; - } - if (part.startsWith('-')) { - continue; - } - if (/^[A-Za-z0-9_]+=/.test(part)) { - continue; - } - if (part.includes('://')) { - continue; - } - return part; - } - return undefined; -} - -function looksLikeInlineCommand(value: string): boolean { - if (!value) { - return false; - } - if (!/\s/.test(value)) { - return false; - } - try { - const parts = splitCommandLine(value.trim()); - return parts.length > 0; - } catch { - return false; - } -} - -function deriveNameFromUrl(url: URL): string | undefined { - const genericHosts = new Set(['www', 'api', 'mcp', 'service', 'services', 'app', 'localhost']); - const knownTlds = new Set(['com', 'net', 'org', 'io', 'ai', 'app', 'dev', 'co', 'cloud']); - const parts = url.hostname.split('.').filter(Boolean); - const filtered = parts.filter((part) => { - const lower = part.toLowerCase(); - if (genericHosts.has(lower)) { - return false; - } - if (knownTlds.has(lower)) { - return false; - } - if (/^\d+$/.test(part)) { - return false; - } - return true; - }); - if (filtered.length > 0) { - const last = filtered[filtered.length - 1]; - if (last) { - return last; - } - } - const segments = url.pathname.split('/').filter(Boolean); - const firstSegment = segments[0]; - if (firstSegment) { - return firstSegment.replace(/[^a-zA-Z0-9-_]/g, '-'); - } - return undefined; -} - -function parseInlineCommand(value: string): InlineCommandSpec { - const parts = splitCommandLine(value.trim()); - if (parts.length === 0) { - throw new Error('--command requires a non-empty value.'); - } - const [binary, ...rest] = parts as [string, ...string[]]; - return rest.length > 0 ? { command: binary, args: rest } : { command: binary }; -} - -function normalizeCommandInput(value: string): CommandInput { - const target = extractHttpServerTarget(value); - if (target) { - return target; - } - return parseInlineCommand(value); -} - -export const __test = { - parseGenerateFlags, - normalizeCommandInput, - inferNameFromCommand, - deriveNameFromUrl, -}; diff --git a/src/cli/generate/flags.ts b/src/cli/generate/flags.ts new file mode 100644 index 0000000..27e45b2 --- /dev/null +++ b/src/cli/generate/flags.ts @@ -0,0 +1,201 @@ +import { splitCommandLine } from '../adhoc-server.js'; +import { expectValue } from '../flag-utils.js'; +import { extractHttpServerTarget, looksLikeHttpUrl, normalizeHttpUrlCandidate } from '../http-utils.js'; +import { extractGeneratorFlags } from './flag-parser.js'; +import type { CommandInput } from './types.js'; + +export interface GenerateFlags { + server?: string; + name?: string; + command?: CommandInput; + description?: string; + output?: string; + bundler?: 'rolldown' | 'bun'; + bundle?: boolean | string; + compile?: boolean | string; + runtime?: 'node' | 'bun'; + timeout: number; + minify?: boolean; + from?: string; + dryRun: boolean; +} + +export function parseGenerateFlags(args: string[]): GenerateFlags { + const common = extractGeneratorFlags(args); + let server: string | undefined; + let name: string | undefined; + let command: CommandInput | undefined; + let description: string | undefined; + let output: string | undefined; + let bundler: 'rolldown' | 'bun' | undefined; + let bundle: boolean | string | undefined; + let compile: boolean | string | undefined; + const runtime: 'node' | 'bun' | undefined = common.runtime; + const timeout = common.timeout ?? 30_000; + let minify: boolean | undefined; + let from: string | undefined; + let dryRun = false; + + let index = 0; + while (index < args.length) { + const token = args[index]; + if (!token) { + index += 1; + continue; + } + if (token === '--from') { + from = expectValue(token, args[index + 1]); + args.splice(index, 2); + continue; + } + if (token === '--dry-run') { + dryRun = true; + args.splice(index, 1); + continue; + } + if (token === '--server') { + server = expectValue(token, args[index + 1]); + args.splice(index, 2); + continue; + } + if (token === '--name') { + name = expectValue(token, args[index + 1]); + args.splice(index, 2); + continue; + } + if (token === '--command') { + const value = expectValue(token, args[index + 1]); + command = normalizeCommandInput(value); + args.splice(index, 2); + continue; + } + if (token === '--description') { + description = expectValue(token, args[index + 1]); + args.splice(index, 2); + continue; + } + if (token === '--output') { + output = expectValue(token, args[index + 1]); + args.splice(index, 2); + continue; + } + if (token === '--bundle') { + const next = args[index + 1]; + if (!next || next.startsWith('--')) { + bundle = true; + args.splice(index, 1); + } else { + bundle = next; + args.splice(index, 2); + } + continue; + } + if (token === '--bundler') { + const value = expectValue(token, args[index + 1]); + if (value !== 'rolldown' && value !== 'bun') { + throw new Error("--bundler must be 'rolldown' or 'bun'."); + } + bundler = value; + args.splice(index, 2); + continue; + } + if (token === '--compile') { + const next = args[index + 1]; + if (!next || next.startsWith('--')) { + compile = true; + args.splice(index, 1); + } else { + compile = next; + args.splice(index, 2); + } + continue; + } + if (token === '--minify') { + minify = true; + args.splice(index, 1); + continue; + } + if (token === '--no-minify') { + minify = false; + args.splice(index, 1); + continue; + } + if (token.startsWith('--')) { + throw new Error(`Unknown flag '${token}' for generate-cli.`); + } + index += 1; + } + + const positional = !server && !command && !from ? args.find((token) => token && !token.startsWith('--')) : undefined; + if (positional) { + const position = args.indexOf(positional); + if (position !== -1) { + args.splice(position, 1); + } + if (looksLikeInlineCommand(positional)) { + command = normalizeCommandInput(positional); + } else if (looksLikeHttpUrl(positional) || positional.includes('://')) { + command = positional; + } else { + server = positional; + } + } + + // translate shorthand env:/URL into normalized http url + if (!server && !command && common.runtime === 'node' && common.timeout && !from && args[0]) { + const target = extractHttpServerTarget(args[0]); + if (target) { + server = target; + } + } + + return { + server, + name, + command, + description, + output, + bundler, + bundle, + compile, + runtime, + timeout, + minify, + from, + dryRun, + }; +} + +function normalizeCommandInput(value: string): CommandInput { + if (/^https?:\/\//i.test(value)) { + return { command: normalizeHttpUrlCandidate(value) ?? value }; + } + if (looksLikeInlineCommand(value)) { + return parseInlineCommand(value); + } + return { command: value }; +} + +function looksLikeInlineCommand(value: string): boolean { + if (!value) { + return false; + } + if (!/\s/.test(value)) { + return false; + } + try { + const parts = splitCommandLine(value.trim()); + return parts.length > 0; + } catch { + return false; + } +} + +function parseInlineCommand(value: string): CommandInput { + const parts = splitCommandLine(value.trim()); + if (parts.length === 0) { + throw new Error('--command requires a non-empty value.'); + } + const [command, ...rest] = parts as [string, ...string[]]; + return { command, args: rest }; +} diff --git a/src/cli/generate/name-utils.ts b/src/cli/generate/name-utils.ts new file mode 100644 index 0000000..46dcee8 --- /dev/null +++ b/src/cli/generate/name-utils.ts @@ -0,0 +1,112 @@ +import { splitCommandLine } from '../adhoc-server.js'; +import type { CommandInput } from './types.js'; + +export function inferNameFromCommand(command: CommandInput): string | undefined { + if (typeof command === 'string') { + const trimmed = command.trim(); + if (looksLikeInlineCommand(trimmed)) { + try { + const parsed = parseInlineCommand(trimmed); + const derived = inferNameFromCommand(parsed); + if (derived) { + return derived; + } + } catch { + // unable to parse; fall through to token heuristic + } + } + const firstToken = trimmed.split(/\s+/)[0] ?? trimmed; + const candidateToken = firstToken.split(/[\\/]/).pop() ?? firstToken; + return slugify(candidateToken.replace(/\.[a-z0-9]+$/i, '')); + } + const parts = [command.command, ...(command.args ?? [])]; + if (parts.length === 0) { + return undefined; + } + const script = parts.find((part) => /\.[cm]?(ts|js)x?$/i.test(part)); + if (script) { + return slugify(stripExtension(basename(script))); + } + const packageArg = parts.find((_part, index) => index > 0 && /[@/]/.test(_part)); + if (packageArg) { + return slugify(packageArg.replace(/^@/, '').split('@')[0] ?? packageArg); + } + const bareArg = findLastPositionalArg(parts); + if (bareArg) { + return slugify(bareArg); + } + return slugify(basename(parts[0] ?? 'command')); +} + +export function normalizeCommandInput(value: string): CommandInput { + if (looksLikeInlineCommand(value)) { + return parseInlineCommand(value); + } + return { command: value }; +} + +export function looksLikeInlineCommand(value: string): boolean { + if (!value) { + return false; + } + if (!/\s/.test(value)) { + return false; + } + try { + const parts = splitCommandLine(value.trim()); + return parts.length > 0; + } catch { + return false; + } +} + +function parseInlineCommand(value: string): CommandInput { + const parts = splitCommandLine(value.trim()); + if (parts.length === 0) { + throw new Error('--command requires a non-empty value.'); + } + const [command, ...rest] = parts as [string, ...string[]]; + return { command, args: rest }; +} + +function slugify(value: string): string | undefined { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || undefined; +} + +function basename(value: string): string { + const segments = value.split(/[\\/]/); + return segments[segments.length - 1] ?? value; +} + +function stripExtension(value: string): string { + const index = value.lastIndexOf('.'); + if (index === -1) { + return value; + } + return value.slice(0, index); +} + +function findLastPositionalArg(parts: string[]): string | undefined { + for (let index = parts.length - 1; index >= 1; index -= 1) { + const part = parts[index]; + if (!part) { + continue; + } + if (part.startsWith('-')) { + continue; + } + if (/^[A-Za-z0-9_]+=/.test(part)) { + continue; + } + if (part.includes('://')) { + continue; + } + return part; + } + return undefined; +} diff --git a/src/cli/generate/output.ts b/src/cli/generate/output.ts new file mode 100644 index 0000000..14cf956 --- /dev/null +++ b/src/cli/generate/output.ts @@ -0,0 +1,27 @@ +import type { CliArtifactMetadata } from '../../cli-metadata.js'; +import { type GenerateCliOptions, generateCli } from '../../generate-cli.js'; + +export async function performGenerateFromArtifact( + metadata: CliArtifactMetadata, + request: GenerateCliOptions +): Promise { + const { outputPath, bundlePath, compilePath } = await generateCli(request); + if (metadata.artifact.kind === 'binary' && compilePath) { + console.log(`Regenerated compiled CLI at ${compilePath}`); + } else if (metadata.artifact.kind === 'bundle' && bundlePath) { + console.log(`Regenerated bundled CLI at ${bundlePath}`); + } else { + console.log(`Regenerated template at ${outputPath}`); + } +} + +export async function performGenerateFromRequest(request: GenerateCliOptions): Promise { + const { outputPath, bundlePath, compilePath } = await generateCli(request); + console.log(`Generated CLI at ${outputPath}`); + if (bundlePath) { + console.log(`Bundled executable created at ${bundlePath}`); + } + if (compilePath) { + console.log(`Compiled executable created at ${compilePath}`); + } +} diff --git a/src/cli/generate/server-utils.ts b/src/cli/generate/server-utils.ts new file mode 100644 index 0000000..8dce802 --- /dev/null +++ b/src/cli/generate/server-utils.ts @@ -0,0 +1,31 @@ +import { normalizeHttpUrlCandidate } from '../http-utils.js'; +import type { CommandInput } from './types.js'; + +export function buildInlineServerDefinition( + name: string, + command: CommandInput, + description?: string +): Record { + if (typeof command === 'string') { + const url = normalizeHttpUrlCandidate(command) ?? command; + return { + name, + description, + command: { + kind: 'http', + url: new URL(url), + }, + source: { kind: 'local', path: '' }, + }; + } + return { + name, + description, + command: { + kind: 'stdio', + command: command.command, + args: command.args ?? [], + }, + source: { kind: 'local', path: '' }, + }; +} diff --git a/src/cli/generate/template-data.ts b/src/cli/generate/template-data.ts new file mode 100644 index 0000000..d2c257b --- /dev/null +++ b/src/cli/generate/template-data.ts @@ -0,0 +1,103 @@ +import type { CliArtifactMetadata, SerializedServerDefinition } from '../../cli-metadata.js'; +import type { GenerateCliOptions } from '../../generate-cli.js'; + +export type InspectableInvocation = CliArtifactMetadata['invocation'] & { + serverRef?: string; +}; + +export interface GenerateCliContext { + invocation: InspectableInvocation; + definition: SerializedServerDefinition; +} + +export function buildGenerateCliCommand( + invocation: InspectableInvocation, + definition: SerializedServerDefinition, + globalFlags: Record = {} +): string { + const tokens: string[] = ['mcporter']; + const configPath = invocation.configPath ?? globalFlags['--config']; + const rootDir = invocation.rootDir ?? globalFlags['--root']; + if (configPath) { + tokens.push('--config', configPath); + } + if (rootDir) { + tokens.push('--root', rootDir); + } + tokens.push('generate-cli'); + + const serverRef = invocation.serverRef ?? definition.name ?? JSON.stringify(definition); + tokens.push('--server', serverRef); + + if (invocation.outputPath) { + tokens.push('--output', invocation.outputPath); + } + if (invocation.bundler && invocation.bundler !== 'rolldown') { + tokens.push('--bundler', invocation.bundler); + } + if (typeof invocation.bundle === 'string') { + tokens.push('--bundle', invocation.bundle); + } else if (invocation.bundle) { + tokens.push('--bundle'); + } + if (typeof invocation.compile === 'string') { + tokens.push('--compile', invocation.compile); + } else if (invocation.compile) { + tokens.push('--compile'); + } + if (invocation.runtime) { + tokens.push('--runtime', invocation.runtime); + } + if (invocation.timeoutMs && invocation.timeoutMs !== 30_000) { + tokens.push('--timeout', String(invocation.timeoutMs)); + } + if (invocation.minify) { + tokens.push('--minify'); + } + return tokens.map(shellQuote).join(' '); +} + +export function resolveGenerateRequestFromArtifact( + parsed: { + from?: string; + server?: string; + output?: string; + runtime?: GenerateCliOptions['runtime']; + bundler?: GenerateCliOptions['bundler']; + bundle?: GenerateCliOptions['bundle']; + timeout: number; + compile?: GenerateCliOptions['compile']; + minify?: boolean; + }, + metadata: CliArtifactMetadata, + globalFlags: Record +): GenerateCliOptions { + if (!parsed.from) { + throw new Error('Missing --from artifact path.'); + } + const invocation = { ...metadata.invocation }; + const serverRef = + parsed.server ?? invocation.serverRef ?? metadata.server.name ?? JSON.stringify(metadata.server.definition); + if (!serverRef) { + throw new Error('Unable to determine server definition from artifact; pass --server with a target name.'); + } + return { + serverRef, + configPath: globalFlags['--config'] ?? invocation.configPath, + rootDir: globalFlags['--root'] ?? invocation.rootDir, + outputPath: parsed.output ?? invocation.outputPath, + runtime: parsed.runtime ?? invocation.runtime, + bundler: parsed.bundler ?? invocation.bundler, + bundle: parsed.bundle ?? invocation.bundle, + timeoutMs: parsed.timeout ?? invocation.timeoutMs, + compile: parsed.compile ?? invocation.compile, + minify: parsed.minify ?? invocation.minify ?? false, + }; +} + +export function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./@%-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/cli/generate/types.ts b/src/cli/generate/types.ts new file mode 100644 index 0000000..518fbdd --- /dev/null +++ b/src/cli/generate/types.ts @@ -0,0 +1 @@ +export type CommandInput = { command: string; args?: string[] } | string; diff --git a/src/cli/inspect-cli-command.ts b/src/cli/inspect-cli-command.ts index ebcbe2d..4ade29b 100644 --- a/src/cli/inspect-cli-command.ts +++ b/src/cli/inspect-cli-command.ts @@ -1,6 +1,6 @@ import { readCliMetadata } from '../cli-metadata.js'; import { expectValue } from './flag-utils.js'; -import { buildGenerateCliCommand, shellQuote } from './generate-cli-runner.js'; +import { buildGenerateCliCommand, shellQuote } from './generate/template-data.js'; import { formatSourceSuffix } from './list-format.js'; import { consumeOutputFormat } from './output-format.js'; import { formatPathForDisplay } from './path-utils.js'; diff --git a/src/runtime.ts b/src/runtime.ts index f3a0f6d..7d8fc81 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,20 +1,14 @@ import { createRequire } from 'node:module'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { CallToolRequest, ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js'; -import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { loadServerDefinitions, type ServerDefinition } from './config.js'; -import { resolveEnvPlaceholders, resolveEnvValue, withEnvOverrides } from './env.js'; import { createPrefixedConsoleLogger, type Logger, type LogLevel, resolveLogLevelFromEnv } from './logging.js'; -import { createOAuthSession, type OAuthSession } from './oauth.js'; -import { materializeHeaders } from './runtime-header-utils.js'; -import { isUnauthorizedError, maybeEnableOAuth } from './runtime-oauth-support.js'; import { closeTransportAndWait } from './runtime-process-utils.js'; import './sdk-patches.js'; +import { shouldResetConnection } from './runtime/errors.js'; +import { resolveOAuthTimeoutFromEnv } from './runtime/oauth.js'; +import { type ClientContext, createClientContext } from './runtime/transport.js'; +import { normalizeTimeout, raceWithTimeout } from './runtime/utils.js'; const PACKAGE_NAME = 'mcporter'; // Keep version in one place by reading package.json; fall back gracefully when bundled without it (e.g., bun bundle). @@ -25,24 +19,8 @@ const CLIENT_VERSION = (() => { return process.env.MCPORTER_VERSION ?? '0.0.0-dev'; } })(); -const DEFAULT_OAUTH_CODE_TIMEOUT_MS = 60_000; -const OAUTH_CODE_TIMEOUT_MS = parseOAuthTimeout( - process.env.MCPORTER_OAUTH_TIMEOUT_MS ?? process.env.MCPORTER_OAUTH_TIMEOUT -); export const MCPORTER_VERSION = CLIENT_VERSION; -const STDIO_TRACE_ENABLED = process.env.MCPORTER_STDIO_TRACE === '1'; -const ENV_PLACEHOLDER_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/; - -function parseOAuthTimeout(raw: string | undefined): number { - if (!raw) { - return DEFAULT_OAUTH_CODE_TIMEOUT_MS; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_OAUTH_CODE_TIMEOUT_MS; - } - return parsed; -} +const OAUTH_CODE_TIMEOUT_MS = resolveOAuthTimeoutFromEnv(); export interface RuntimeOptions { readonly configPath?: string; @@ -92,13 +70,6 @@ export interface ServerToolInfo { readonly outputSchema?: unknown; } -interface ClientContext { - readonly client: Client; - readonly transport: Transport & { close(): Promise }; - readonly definition: ServerDefinition; - readonly oauthSession?: OAuthSession; -} - // createRuntime spins up a pooled MCP runtime from config JSON or provided definitions. export async function createRuntime(options: RuntimeOptions = {}): Promise { // Build the runtime with either the provided server list or the config file contents. @@ -130,32 +101,6 @@ export async function callOnce(params: { } } -function resolveCommandArgument(value: string): string { - if (!value) { - return value; - } - if (!value.includes('$')) { - return value; - } - const needsInterpolation = value.startsWith('$env:') || ENV_PLACEHOLDER_PATTERN.test(value); - if (!needsInterpolation) { - return value; - } - return resolveEnvPlaceholders(value); -} - -function resolveCommandArguments(args: readonly string[]): string[] { - if (!args || args.length === 0) { - return []; - } - return args.map((arg) => resolveCommandArgument(arg)); -} - -function attachStdioTraceLogging(_transport: StdioClientTransport, _label?: string): void { - // STDIO instrumentation is handled via sdk-patches side effects. This helper remains - // so runtime callers can opt-in without sprinkling conditional checks everywhere. -} - class McpRuntime implements Runtime { private readonly definitions: Map; private readonly clients = new Map>(); @@ -290,7 +235,11 @@ class McpRuntime implements Runtime { throw new Error(`Unknown MCP server '${normalized}'.`); } - const connection = this.createClient(definition, options); + const connection = createClientContext(definition, this.logger, this.clientInfo, { + maxOAuthAttempts: options.maxOAuthAttempts, + oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS, + onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted), + }); if (useCache) { this.clients.set(normalized, connection); @@ -349,294 +298,6 @@ class McpRuntime implements Runtime { this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`); } } - - // createClient wires up transports, optional OAuth sessions, and connects the MCP client. - private async createClient(definition: ServerDefinition, options: ConnectOptions = {}): Promise { - // Create a fresh MCP client context for the target server. - const client = new Client(this.clientInfo); - let activeDefinition = definition; - - return withEnvOverrides(activeDefinition.env, async () => { - if (activeDefinition.command.kind === 'stdio') { - // Resolve any ${VAR:-fallback} placeholders first so overrides remain deterministic even after - // we merge the caller's environment below. - const resolvedEnvOverrides = - activeDefinition.env && Object.keys(activeDefinition.env).length > 0 - ? Object.fromEntries( - Object.entries(activeDefinition.env) - .map(([key, raw]) => [key, resolveEnvValue(raw)]) - .filter(([, value]) => value !== '') - ) - : undefined; - // Clone process.env so ad-hoc STDIO commands inherit the same environment as the invoking shell, - // then layer config/env overrides on top (without mutating the parent process.env). - const mergedEnv = - resolvedEnvOverrides && Object.keys(resolvedEnvOverrides).length > 0 - ? { ...process.env, ...resolvedEnvOverrides } - : { ...process.env }; - const transport = new StdioClientTransport({ - command: resolveCommandArgument(activeDefinition.command.command), - args: resolveCommandArguments(activeDefinition.command.args), - cwd: activeDefinition.command.cwd, - env: mergedEnv, - }); - if (STDIO_TRACE_ENABLED) { - attachStdioTraceLogging(transport, activeDefinition.name ?? activeDefinition.command.command); - } - try { - await client.connect(transport); - } catch (error) { - // Ensure STDIO transports are torn down when connect() fails so child processes - // (and their logged stdout/stderr) are not left running in the background. - await closeTransportAndWait(this.logger, transport).catch(() => {}); - throw error; - } - return { client, transport, definition: activeDefinition, oauthSession: undefined }; - } - - // HTTP transports may need to retry once OAuth is auto-enabled. - while (true) { - const command = activeDefinition.command; - if (command.kind !== 'http') { - throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`); - } - let oauthSession: OAuthSession | undefined; - const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0; - if (shouldEstablishOAuth) { - oauthSession = await createOAuthSession(activeDefinition, this.logger); - } - - const resolvedHeaders = materializeHeaders(command.headers, activeDefinition.name); - - const requestInit: RequestInit | undefined = resolvedHeaders - ? { headers: resolvedHeaders as HeadersInit } - : undefined; - - const baseOptions = { - requestInit, - authProvider: oauthSession?.provider, - }; - - const attemptConnect = async () => { - const streamableTransport = new StreamableHTTPClientTransport(command.url, baseOptions); - try { - await this.connectWithAuth( - client, - streamableTransport, - oauthSession, - activeDefinition.name, - options.maxOAuthAttempts - ); - return { - client, - transport: streamableTransport, - definition: activeDefinition, - oauthSession, - } as ClientContext; - } catch (error) { - await closeTransportAndWait(this.logger, streamableTransport).catch(() => {}); - throw error; - } - }; - - try { - return await attemptConnect(); - } catch (primaryError) { - if (isUnauthorizedError(primaryError)) { - await oauthSession?.close().catch(() => {}); - oauthSession = undefined; - const promoted = maybeEnableOAuth(activeDefinition, this.logger); - if (promoted && options.maxOAuthAttempts !== 0) { - activeDefinition = promoted; - this.definitions.set(promoted.name, promoted); - continue; - } - } - if (primaryError instanceof OAuthTimeoutError) { - await oauthSession?.close().catch(() => {}); - throw primaryError; - } - if (primaryError instanceof Error) { - this.logger.info(`Falling back to SSE transport for '${activeDefinition.name}': ${primaryError.message}`); - } - const sseTransport = new SSEClientTransport(command.url, { - ...baseOptions, - }); - try { - await this.connectWithAuth( - client, - sseTransport, - oauthSession, - activeDefinition.name, - options.maxOAuthAttempts - ); - return { client, transport: sseTransport, definition: activeDefinition, oauthSession }; - } catch (sseError) { - await closeTransportAndWait(this.logger, sseTransport).catch(() => {}); - await oauthSession?.close().catch(() => {}); - if (sseError instanceof OAuthTimeoutError) { - throw sseError; - } - if (isUnauthorizedError(sseError) && options.maxOAuthAttempts !== 0) { - const promoted = maybeEnableOAuth(activeDefinition, this.logger); - if (promoted) { - activeDefinition = promoted; - this.definitions.set(promoted.name, promoted); - continue; - } - } - throw sseError; - } - } - } - }); - } - - // connectWithAuth retries MCP connect calls while the OAuth flow progresses. - private async connectWithAuth( - client: Client, - transport: Transport & { - close(): Promise; - finishAuth?: (authorizationCode: string) => Promise; - }, - session?: OAuthSession, - serverName?: string, - maxAttempts = 3 - ): Promise { - let attempt = 0; - while (true) { - try { - await client.connect(transport); - return; - } catch (error) { - if (!isUnauthorizedError(error) || !session) { - throw error; - } - attempt += 1; - if (attempt > maxAttempts) { - throw error; - } - this.logger.warn( - `OAuth authorization required for '${serverName ?? 'unknown'}'. Waiting for browser approval...` - ); - try { - const code = await waitForAuthorizationCodeWithTimeout( - session, - this.logger, - serverName, - this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS - ); - if (typeof transport.finishAuth === 'function') { - await transport.finishAuth(code); - this.logger.info('Authorization code accepted. Retrying connection...'); - } else { - this.logger.warn('Transport does not support finishAuth; cannot complete OAuth flow automatically.'); - throw error; - } - } catch (authError) { - this.logger.error('OAuth authorization failed while waiting for callback.', authError); - throw authError; - } - } - } - } -} - -class OAuthTimeoutError extends Error { - public readonly timeoutMs: number; - public readonly serverName: string; - - constructor(serverName: string, timeoutMs: number) { - const seconds = Math.round(timeoutMs / 1000); - super(`OAuth authorization for '${serverName}' timed out after ${seconds}s; aborting.`); - this.name = 'OAuthTimeoutError'; - this.timeoutMs = timeoutMs; - this.serverName = serverName; - } -} - -export const __test = { - maybeEnableOAuth, - isUnauthorizedError, - waitForAuthorizationCodeWithTimeout, - OAuthTimeoutError, - resolveCommandArgument, -}; - -// Race the pending OAuth browser handshake so the runtime can't sit on an unresolved promise forever. -function waitForAuthorizationCodeWithTimeout( - session: OAuthSession, - logger: RuntimeLogger, - serverName?: string, - timeoutMs = OAUTH_CODE_TIMEOUT_MS -): Promise { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - return session.waitForAuthorizationCode(); - } - const displayName = serverName ?? 'unknown'; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const error = new OAuthTimeoutError(displayName, timeoutMs); - logger.warn(error.message); - reject(error); - }, timeoutMs); - session.waitForAuthorizationCode().then( - (code) => { - clearTimeout(timer); - resolve(code); - }, - (error) => { - clearTimeout(timer); - reject(error); - } - ); - }); -} - -function normalizeTimeout(raw?: number): number | undefined { - if (raw == null) { - return undefined; - } - if (!Number.isFinite(raw)) { - return undefined; - } - const coerced = Math.trunc(raw); - return coerced > 0 ? coerced : undefined; -} - -function raceWithTimeout(promise: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - // Reject with a Timeout error; higher-level catch blocks decide whether to recycle the transport. - reject(new Error('Timeout')); - }, timeoutMs); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - } - ); - }); -} - -const NON_FATAL_MCP_ERROR_CODES = new Set([ - ErrorCode.InvalidRequest, - ErrorCode.MethodNotFound, - ErrorCode.InvalidParams, -]); - -function shouldResetConnection(error: unknown): boolean { - if (!error) { - return false; - } - if (error instanceof McpError) { - return !NON_FATAL_MCP_ERROR_CODES.has(error.code); - } - return error instanceof Error; } // createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL. diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts new file mode 100644 index 0000000..75ce1f4 --- /dev/null +++ b/src/runtime/errors.ts @@ -0,0 +1,17 @@ +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; + +const NON_FATAL_MCP_ERROR_CODES = new Set([ + ErrorCode.InvalidRequest, + ErrorCode.MethodNotFound, + ErrorCode.InvalidParams, +]); + +export function shouldResetConnection(error: unknown): boolean { + if (!error) { + return false; + } + if (error instanceof McpError) { + return !NON_FATAL_MCP_ERROR_CODES.has(error.code); + } + return error instanceof Error; +} diff --git a/src/runtime/oauth.ts b/src/runtime/oauth.ts new file mode 100644 index 0000000..8128cc2 --- /dev/null +++ b/src/runtime/oauth.ts @@ -0,0 +1,112 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Logger } from '../logging.js'; +import type { OAuthSession } from '../oauth.js'; +import { isUnauthorizedError } from '../runtime-oauth-support.js'; + +export const DEFAULT_OAUTH_CODE_TIMEOUT_MS = 60_000; + +export class OAuthTimeoutError extends Error { + public readonly timeoutMs: number; + public readonly serverName: string; + + constructor(serverName: string, timeoutMs: number) { + const seconds = Math.round(timeoutMs / 1000); + super(`OAuth authorization for '${serverName}' timed out after ${seconds}s; aborting.`); + this.name = 'OAuthTimeoutError'; + this.timeoutMs = timeoutMs; + this.serverName = serverName; + } +} + +export async function connectWithAuth( + client: Client, + transport: Transport & { + close(): Promise; + finishAuth?: (authorizationCode: string) => Promise; + }, + session: OAuthSession | undefined, + logger: Logger, + options: { serverName?: string; maxAttempts?: number; oauthTimeoutMs?: number } = {} +): Promise { + const { serverName, maxAttempts = 3, oauthTimeoutMs = DEFAULT_OAUTH_CODE_TIMEOUT_MS } = options; + let attempt = 0; + while (true) { + try { + await client.connect(transport); + return; + } catch (error) { + if (!isUnauthorizedError(error) || !session) { + throw error; + } + attempt += 1; + if (attempt > maxAttempts) { + throw error; + } + logger.warn(`OAuth authorization required for '${serverName ?? 'unknown'}'. Waiting for browser approval...`); + try { + const code = await waitForAuthorizationCodeWithTimeout( + session, + logger, + serverName, + oauthTimeoutMs ?? DEFAULT_OAUTH_CODE_TIMEOUT_MS + ); + if (typeof transport.finishAuth === 'function') { + await transport.finishAuth(code); + logger.info('Authorization code accepted. Retrying connection...'); + } else { + logger.warn('Transport does not support finishAuth; cannot complete OAuth flow automatically.'); + throw error; + } + } catch (authError) { + logger.error('OAuth authorization failed while waiting for callback.', authError); + throw authError; + } + } + } +} + +// Race the pending OAuth browser handshake so the runtime can't sit on an unresolved promise forever. +export function waitForAuthorizationCodeWithTimeout( + session: OAuthSession, + logger: Logger, + serverName?: string, + timeoutMs = DEFAULT_OAUTH_CODE_TIMEOUT_MS +): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return session.waitForAuthorizationCode(); + } + const displayName = serverName ?? 'unknown'; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const error = new OAuthTimeoutError(displayName, timeoutMs); + logger.warn(error.message); + reject(error); + }, timeoutMs); + session.waitForAuthorizationCode().then( + (code) => { + clearTimeout(timer); + resolve(code); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +export function parseOAuthTimeout(raw: string | undefined): number { + if (!raw) { + return DEFAULT_OAUTH_CODE_TIMEOUT_MS; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_OAUTH_CODE_TIMEOUT_MS; + } + return parsed; +} + +export function resolveOAuthTimeoutFromEnv(): number { + return parseOAuthTimeout(process.env.MCPORTER_OAUTH_TIMEOUT_MS ?? process.env.MCPORTER_OAUTH_TIMEOUT); +} diff --git a/src/runtime/transport.ts b/src/runtime/transport.ts new file mode 100644 index 0000000..d3609b2 --- /dev/null +++ b/src/runtime/transport.ts @@ -0,0 +1,168 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { ServerDefinition } from '../config.js'; +import { resolveEnvValue, withEnvOverrides } from '../env.js'; +import type { Logger } from '../logging.js'; +import { createOAuthSession, type OAuthSession } from '../oauth.js'; +import { materializeHeaders } from '../runtime-header-utils.js'; +import { isUnauthorizedError, maybeEnableOAuth } from '../runtime-oauth-support.js'; +import { closeTransportAndWait } from '../runtime-process-utils.js'; +import { connectWithAuth, OAuthTimeoutError } from './oauth.js'; +import { resolveCommandArgument, resolveCommandArguments } from './utils.js'; + +const STDIO_TRACE_ENABLED = process.env.MCPORTER_STDIO_TRACE === '1'; + +function attachStdioTraceLogging(_transport: StdioClientTransport, _label?: string): void { + // STDIO instrumentation is handled via sdk-patches side effects. This helper remains + // so runtime callers can opt-in without sprinkling conditional checks everywhere. +} + +export interface ClientContext { + readonly client: Client; + readonly transport: Transport & { close(): Promise }; + readonly definition: ServerDefinition; + readonly oauthSession?: OAuthSession; +} + +export interface CreateClientContextOptions { + readonly maxOAuthAttempts?: number; + readonly oauthTimeoutMs?: number; + readonly onDefinitionPromoted?: (definition: ServerDefinition) => void; +} + +export async function createClientContext( + definition: ServerDefinition, + logger: Logger, + clientInfo: { name: string; version: string }, + options: CreateClientContextOptions = {} +): Promise { + const client = new Client(clientInfo); + let activeDefinition = definition; + + return withEnvOverrides(activeDefinition.env, async () => { + if (activeDefinition.command.kind === 'stdio') { + const resolvedEnvOverrides = + activeDefinition.env && Object.keys(activeDefinition.env).length > 0 + ? Object.fromEntries( + Object.entries(activeDefinition.env) + .map(([key, raw]) => [key, resolveEnvValue(raw)]) + .filter(([, value]) => value !== '') + ) + : undefined; + const mergedEnv = + resolvedEnvOverrides && Object.keys(resolvedEnvOverrides).length > 0 + ? { ...process.env, ...resolvedEnvOverrides } + : { ...process.env }; + const transport = new StdioClientTransport({ + command: resolveCommandArgument(activeDefinition.command.command), + args: resolveCommandArguments(activeDefinition.command.args), + cwd: activeDefinition.command.cwd, + env: mergedEnv, + }); + if (STDIO_TRACE_ENABLED) { + attachStdioTraceLogging(transport, activeDefinition.name ?? activeDefinition.command.command); + } + try { + await client.connect(transport); + } catch (error) { + await closeTransportAndWait(logger, transport).catch(() => {}); + throw error; + } + return { client, transport, definition: activeDefinition, oauthSession: undefined }; + } + + while (true) { + const command = activeDefinition.command; + if (command.kind !== 'http') { + throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`); + } + let oauthSession: OAuthSession | undefined; + const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0; + if (shouldEstablishOAuth) { + oauthSession = await createOAuthSession(activeDefinition, logger); + } + + const resolvedHeaders = materializeHeaders(command.headers, activeDefinition.name); + const requestInit: RequestInit | undefined = resolvedHeaders + ? { headers: resolvedHeaders as HeadersInit } + : undefined; + const baseOptions = { + requestInit, + authProvider: oauthSession?.provider, + }; + + const attemptConnect = async () => { + const streamableTransport = new StreamableHTTPClientTransport(command.url, baseOptions); + try { + await connectWithAuth(client, streamableTransport, oauthSession, logger, { + serverName: activeDefinition.name, + maxAttempts: options.maxOAuthAttempts, + oauthTimeoutMs: options.oauthTimeoutMs, + }); + return { + client, + transport: streamableTransport, + definition: activeDefinition, + oauthSession, + } as ClientContext; + } catch (error) { + await closeTransportAndWait(logger, streamableTransport).catch(() => {}); + throw error; + } + }; + + try { + return await attemptConnect(); + } catch (primaryError) { + if (isUnauthorizedError(primaryError)) { + await oauthSession?.close().catch(() => {}); + oauthSession = undefined; + if (options.maxOAuthAttempts !== 0) { + const promoted = maybeEnableOAuth(activeDefinition, logger); + if (promoted) { + activeDefinition = promoted; + options.onDefinitionPromoted?.(promoted); + continue; + } + } + } + if (primaryError instanceof OAuthTimeoutError) { + await oauthSession?.close().catch(() => {}); + throw primaryError; + } + if (primaryError instanceof Error) { + logger.info(`Falling back to SSE transport for '${activeDefinition.name}': ${primaryError.message}`); + } + const sseTransport = new SSEClientTransport(command.url, { + ...baseOptions, + }); + try { + await connectWithAuth(client, sseTransport, oauthSession, logger, { + serverName: activeDefinition.name, + maxAttempts: options.maxOAuthAttempts, + oauthTimeoutMs: options.oauthTimeoutMs, + }); + return { client, transport: sseTransport, definition: activeDefinition, oauthSession }; + } catch (sseError) { + await closeTransportAndWait(logger, sseTransport).catch(() => {}); + await oauthSession?.close().catch(() => {}); + if (sseError instanceof OAuthTimeoutError) { + throw sseError; + } + if (isUnauthorizedError(sseError) && options.maxOAuthAttempts !== 0) { + const promoted = maybeEnableOAuth(activeDefinition, logger); + if (promoted) { + activeDefinition = promoted; + options.onDefinitionPromoted?.(promoted); + continue; + } + } + throw sseError; + } + } + } + }); +} diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts new file mode 100644 index 0000000..5864f07 --- /dev/null +++ b/src/runtime/utils.ts @@ -0,0 +1,54 @@ +import { resolveEnvPlaceholders } from '../env.js'; + +const ENV_PLACEHOLDER_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/; + +export function resolveCommandArgument(value: string): string { + if (!value) { + return value; + } + if (!value.includes('$')) { + return value; + } + const needsInterpolation = value.startsWith('$env:') || ENV_PLACEHOLDER_PATTERN.test(value); + if (!needsInterpolation) { + return value; + } + return resolveEnvPlaceholders(value); +} + +export function resolveCommandArguments(args: readonly string[]): string[] { + if (!args || args.length === 0) { + return []; + } + return args.map((arg) => resolveCommandArgument(arg)); +} + +export function normalizeTimeout(raw?: number): number | undefined { + if (raw == null) { + return undefined; + } + if (!Number.isFinite(raw)) { + return undefined; + } + const coerced = Math.trunc(raw); + return coerced > 0 ? coerced : undefined; +} + +export function raceWithTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // Reject with a Timeout error; higher-level catch blocks decide whether to recycle the transport. + reject(new Error('Timeout')); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} diff --git a/tests/cli-generate-runner.test.ts b/tests/cli-generate-runner.test.ts index 83dedd6..867dd30 100644 --- a/tests/cli-generate-runner.test.ts +++ b/tests/cli-generate-runner.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { buildGenerateCliCommand, __test as generateInternals } from '../src/cli/generate-cli-runner.js'; +import { parseGenerateFlags } from '../src/cli/generate/flags.js'; +import { inferNameFromCommand } from '../src/cli/generate/name-utils.js'; +import { buildGenerateCliCommand } from '../src/cli/generate/template-data.js'; import type { SerializedServerDefinition } from '../src/cli-metadata.js'; describe('generate-cli runner internals', () => { @@ -13,7 +15,7 @@ describe('generate-cli runner internals', () => { '--compile', '--minify', ]; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(parsed.server).toBe('linear'); expect(parsed.command).toBe('https://example.com/mcp'); expect(parsed.bundle).toBe(true); @@ -23,64 +25,64 @@ describe('generate-cli runner internals', () => { it('normalizes inferred names from URLs', () => { const args = ['--command', 'https://api.linear.app/mcp.getComponents']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(parsed.command).toContain('https://'); - const inferred = generateInternals.inferNameFromCommand(parsed.command ?? ''); + const inferred = inferNameFromCommand(parsed.command ?? ''); expect(inferred).toBe('linear'); }); it('splits stdio commands and infers names from args', () => { const args = ['--command', 'npx -y chrome-devtools-mcp@latest']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(parsed.command).toBeDefined(); expect(typeof parsed.command).toBe('object'); const spec = parsed.command as { command: string; args?: string[] }; expect(spec.command).toBe('npx'); expect(spec.args).toEqual(['-y', 'chrome-devtools-mcp@latest']); - const inferred = parsed.command !== undefined ? generateInternals.inferNameFromCommand(parsed.command) : undefined; + const inferred = parsed.command !== undefined ? inferNameFromCommand(parsed.command) : undefined; expect(inferred).toContain('chrome-devtools'); }); it('parses local script commands with extra args', () => { const args = ['--command', 'bun run ./servers/local-cli.ts --stdio --name local']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); const spec = parsed.command as { command: string; args?: string[] }; expect(spec.command).toBe('bun'); expect(spec.args).toEqual(['run', './servers/local-cli.ts', '--stdio', '--name', 'local']); - const inferred = parsed.command !== undefined ? generateInternals.inferNameFromCommand(parsed.command) : undefined; + const inferred = parsed.command !== undefined ? inferNameFromCommand(parsed.command) : undefined; expect(inferred).toBe('local-cli'); }); it('infers package names from scoped arguments', () => { const args = ['--command', 'npx -y @demo/tools@latest serve']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); const spec = parsed.command as { command: string; args?: string[] }; expect(spec.args).toEqual(['-y', '@demo/tools@latest', 'serve']); - const inferred = parsed.command !== undefined ? generateInternals.inferNameFromCommand(parsed.command) : undefined; + const inferred = parsed.command !== undefined ? inferNameFromCommand(parsed.command) : undefined; expect(inferred).toBe('demo-tools'); }); it('infers npm package names without version specifiers in inline commands', () => { const args = ['--command', 'npx -y chrome-devtools-mcp']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); const spec = parsed.command as { command: string; args?: string[] }; expect(spec.args).toEqual(['-y', 'chrome-devtools-mcp']); - const inferred = parsed.command !== undefined ? generateInternals.inferNameFromCommand(parsed.command) : undefined; + const inferred = parsed.command !== undefined ? inferNameFromCommand(parsed.command) : undefined; expect(inferred).toBe('chrome-devtools-mcp'); }); it('normalizes scheme-less HTTP selectors passed to --command', () => { const args = ['--command', 'shadcn.io/api/mcp.getComponents']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(typeof parsed.command).toBe('string'); expect((parsed.command as string).startsWith('https://')).toBe(true); - const inferred = parsed.command !== undefined ? generateInternals.inferNameFromCommand(parsed.command) : undefined; + const inferred = parsed.command !== undefined ? inferNameFromCommand(parsed.command) : undefined; expect(inferred).toBe('shadcn'); }); it('treats positional inline commands as generate-cli targets', () => { const args = ['npx -y chrome-devtools-mcp@latest']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(parsed.command).toBeDefined(); expect(parsed.server).toBeUndefined(); const spec = parsed.command as { command: string; args?: string[] }; @@ -90,14 +92,14 @@ describe('generate-cli runner internals', () => { it('keeps bare names positional when no whitespace is present', () => { const args = ['linear']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(parsed.server).toBe('linear'); expect(parsed.command).toBeUndefined(); }); it('handles inline commands with extra interior whitespace', () => { const args = [' bun run ./cli.ts --stdio ']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); const spec = parsed.command as { command: string; args?: string[] }; expect(spec.command).toBe('bun'); expect(spec.args).toEqual(['run', './cli.ts', '--stdio']); @@ -105,10 +107,10 @@ describe('generate-cli runner internals', () => { it('treats positional HTTPS URLs as ad-hoc servers and infers names', () => { const args = ['https://mcp.context7.com/mcp']; - const parsed = generateInternals.parseGenerateFlags([...args]); + const parsed = parseGenerateFlags([...args]); expect(parsed.command).toBe('https://mcp.context7.com/mcp'); expect(parsed.server).toBeUndefined(); - const inferred = parsed.command !== undefined ? generateInternals.inferNameFromCommand(parsed.command) : undefined; + const inferred = parsed.command !== undefined ? inferNameFromCommand(parsed.command) : undefined; expect(inferred).toBe('context7'); }); diff --git a/tests/cli-oauth-timeout-flag.test.ts b/tests/cli-oauth-timeout-flag.test.ts index 3856885..581a1a4 100644 --- a/tests/cli-oauth-timeout-flag.test.ts +++ b/tests/cli-oauth-timeout-flag.test.ts @@ -58,7 +58,7 @@ describe('mcporter --oauth-timeout flag', () => { command: { kind: 'http' as const, url: new URL('https://example.com/mcp') }, }; const runtimeModule = await import('../src/runtime.js'); - const TimeoutError = runtimeModule.__test.OAuthTimeoutError; + const { OAuthTimeoutError: TimeoutError } = await import('../src/runtime/oauth.js'); const failingListTools = vi.fn(async () => { throw new TimeoutError('fake', 500); }); diff --git a/tests/runtime-oauth-detection.test.ts b/tests/runtime-oauth-detection.test.ts index 847014b..6210b27 100644 --- a/tests/runtime-oauth-detection.test.ts +++ b/tests/runtime-oauth-detection.test.ts @@ -2,7 +2,7 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import { describe, expect, it, vi } from 'vitest'; import type { ServerDefinition } from '../src/config.js'; -import { __test } from '../src/runtime.js'; +import { isUnauthorizedError, maybeEnableOAuth } from '../src/runtime-oauth-support.js'; const logger = { info: vi.fn(), @@ -18,7 +18,7 @@ describe('maybeEnableOAuth', () => { }; it('returns an updated definition for ad-hoc HTTP servers', () => { - const updated = __test.maybeEnableOAuth(baseDefinition, logger as never); + const updated = maybeEnableOAuth(baseDefinition, logger as never); expect(updated).toBeDefined(); expect(updated?.auth).toBe('oauth'); expect(updated?.tokenCacheDir).toContain('adhoc-server'); @@ -31,7 +31,7 @@ describe('maybeEnableOAuth', () => { command: { kind: 'http', url: new URL('https://example.com') }, source: { kind: 'local', path: '/tmp/config.json' }, }; - const updated = __test.maybeEnableOAuth(def, logger as never); + const updated = maybeEnableOAuth(def, logger as never); expect(updated).toBeUndefined(); }); }); @@ -39,14 +39,14 @@ describe('maybeEnableOAuth', () => { describe('isUnauthorizedError helper', () => { it('matches UnauthorizedError instances', () => { const err = new UnauthorizedError('Unauthorized'); - expect(__test.isUnauthorizedError(err)).toBe(true); + expect(isUnauthorizedError(err)).toBe(true); }); it('matches generic errors with 401 codes', () => { - expect(__test.isUnauthorizedError(new Error('SSE error: Non-200 status code (401)'))).toBe(true); + expect(isUnauthorizedError(new Error('SSE error: Non-200 status code (401)'))).toBe(true); }); it('ignores unrelated errors', () => { - expect(__test.isUnauthorizedError(new Error('network timeout'))).toBe(false); + expect(isUnauthorizedError(new Error('network timeout'))).toBe(false); }); }); diff --git a/tests/runtime-oauth-timeout.test.ts b/tests/runtime-oauth-timeout.test.ts index 80524ff..f11190f 100644 --- a/tests/runtime-oauth-timeout.test.ts +++ b/tests/runtime-oauth-timeout.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { OAuthSession } from '../src/oauth.js'; -import { __test } from '../src/runtime.js'; +import { OAuthTimeoutError, waitForAuthorizationCodeWithTimeout } from '../src/runtime/oauth.js'; describe('waitForAuthorizationCodeWithTimeout', () => { afterEach(() => { @@ -22,8 +22,8 @@ describe('waitForAuthorizationCodeWithTimeout', () => { error: vi.fn(), }; - const promise = __test.waitForAuthorizationCodeWithTimeout(session, logger, 'fake', 1_000); - const expectation = expect(promise).rejects.toBeInstanceOf(__test.OAuthTimeoutError); + const promise = waitForAuthorizationCodeWithTimeout(session, logger, 'fake', 1_000); + const expectation = expect(promise).rejects.toBeInstanceOf(OAuthTimeoutError); await vi.advanceTimersByTimeAsync(1_000); await expectation; expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('timed out after 1s')); @@ -50,7 +50,7 @@ describe('waitForAuthorizationCodeWithTimeout', () => { error: vi.fn(), }; - const promise = __test.waitForAuthorizationCodeWithTimeout(session, logger, 'fake', 1_000); + const promise = waitForAuthorizationCodeWithTimeout(session, logger, 'fake', 1_000); const expectation = expect(promise).resolves.toBe('abc123'); resolveCode?.('abc123'); await vi.advanceTimersByTimeAsync(0); diff --git a/tests/runtime-oauth-utils.test.ts b/tests/runtime-oauth-utils.test.ts new file mode 100644 index 0000000..18d8e42 --- /dev/null +++ b/tests/runtime-oauth-utils.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { parseOAuthTimeout } from '../src/runtime/oauth.js'; + +describe('parseOAuthTimeout', () => { + it('falls back to default on missing or invalid values', () => { + expect(parseOAuthTimeout(undefined)).toBe(60_000); + expect(parseOAuthTimeout('not-a-number')).toBe(60_000); + expect(parseOAuthTimeout('-500')).toBe(60_000); + }); + + it('parses valid integer inputs', () => { + expect(parseOAuthTimeout('45000')).toBe(45_000); + }); +}); diff --git a/tests/runtime-transport.test.ts b/tests/runtime-transport.test.ts new file mode 100644 index 0000000..3512d6f --- /dev/null +++ b/tests/runtime-transport.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ServerDefinition } from '../src/config.js'; +import { createClientContext } from '../src/runtime/transport.js'; + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +const clientInfo = { name: 'mcporter', version: '0.0.0-test' }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function stubHttpDefinition(url: string): ServerDefinition { + return { + name: 'http-server', + command: { kind: 'http', url: new URL(url) }, + source: { kind: 'local', path: '' }, + }; +} + +describe('createClientContext (HTTP)', () => { + it('falls back to SSE when primary connect fails', async () => { + const definition = stubHttpDefinition('https://example.com/mcp'); + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); + const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js'); + const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + + const clientConnect = vi + .spyOn(Client.prototype, 'connect') + .mockImplementationOnce(async (transport) => { + expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); + throw new Error('network down'); + }) + .mockImplementationOnce(async (transport) => { + expect(transport).toBeInstanceOf(SSEClientTransport); + }); + + const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 }); + + expect(context.transport).toBeInstanceOf(SSEClientTransport); + expect(clientConnect).toHaveBeenCalledTimes(2); + }); + + it('promotes ad-hoc HTTP servers to OAuth after unauthorized, then retries', async () => { + const definition = stubHttpDefinition('https://example.com/secure'); + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); + const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js'); + + const clientConnect = vi + .spyOn(Client.prototype, 'connect') + .mockImplementationOnce(async () => { + throw new Error('SSE error: Non-200 status code (401)'); + }) + .mockImplementationOnce(async (transport) => { + expect(transport).toBeInstanceOf(SSEClientTransport); + }); + + const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 }); + + expect(context.definition.auth).toBe('oauth'); + expect(clientConnect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/runtime-utils.test.ts b/tests/runtime-utils.test.ts new file mode 100644 index 0000000..05328b4 --- /dev/null +++ b/tests/runtime-utils.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { normalizeTimeout, raceWithTimeout } from '../src/runtime/utils.js'; + +describe('normalizeTimeout', () => { + it('returns undefined for invalid inputs', () => { + expect(normalizeTimeout(undefined)).toBeUndefined(); + expect(normalizeTimeout(Number.NaN)).toBeUndefined(); + expect(normalizeTimeout(-10)).toBeUndefined(); + expect(normalizeTimeout(0)).toBeUndefined(); + }); + + it('returns a truncated positive integer', () => { + expect(normalizeTimeout(1500.9)).toBe(1500); + }); +}); + +describe('raceWithTimeout', () => { + it('resolves when the promise settles before the timeout', async () => { + const promise = raceWithTimeout(Promise.resolve('ok'), 1_000); + await expect(promise).resolves.toBe('ok'); + }); + + it('rejects with a timeout error when exceeding the deadline', async () => { + vi.useFakeTimers(); + const promise = raceWithTimeout(new Promise(() => {}), 500); + const expectation = expect(promise).rejects.toThrowError('Timeout'); + vi.advanceTimersByTime(500); + await expectation; + vi.useRealTimers(); + }); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 82fd613..a061a4c 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { loadServerDefinitions } from '../src/config.js'; import { resolveEnvPlaceholders, resolveEnvValue, withEnvOverrides } from '../src/env.js'; -import { __test as runtimeTestHelpers } from '../src/runtime.js'; +import { resolveCommandArgument } from '../src/runtime/utils.js'; const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'mcporter.json'); @@ -82,13 +82,13 @@ describe('command argument interpolation', () => { it('resolves placeholder tokens', () => { process.env.CHROME_DEVTOOLS_URL = 'http://127.0.0.1:5555'; const placeholder = String.raw`\${CHROME_DEVTOOLS_URL}`; - const result = runtimeTestHelpers.resolveCommandArgument(`--browserUrl ${placeholder}`); + const result = resolveCommandArgument(`--browserUrl ${placeholder}`); expect(result).toBe('--browserUrl http://127.0.0.1:5555'); }); it('passes through tokens without placeholders', () => { const value = '--browserUrl'; - const result = runtimeTestHelpers.resolveCommandArgument(value); + const result = resolveCommandArgument(value); expect(result).toBe(value); }); });