refactor(cli): split generate-cli runner

This commit is contained in:
Peter Steinberger 2025-11-17 19:15:28 +01:00
parent d0bb8884ad
commit af38e3be40
22 changed files with 1005 additions and 860 deletions

View File

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

View File

@ -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<void> {
@ -34,25 +16,9 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P
throw new Error('--dry-run currently requires --from <artifact>.');
}
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<string, unknown> {
const base: Record<string, unknown> = { 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,
};

201
src/cli/generate/flags.ts Normal file
View File

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

View File

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

View File

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

View File

@ -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<string, unknown> {
if (typeof command === 'string') {
const url = normalizeHttpUrlCandidate(command) ?? command;
return {
name,
description,
command: {
kind: 'http',
url: new URL(url),
},
source: { kind: 'local', path: '<adhoc>' },
};
}
return {
name,
description,
command: {
kind: 'stdio',
command: command.command,
args: command.args ?? [],
},
source: { kind: 'local', path: '<adhoc>' },
};
}

View File

@ -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, string | undefined> = {}
): 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<string, string | undefined>
): 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, `'\\''`)}'`;
}

View File

@ -0,0 +1 @@
export type CommandInput = { command: string; args?: string[] } | string;

View File

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

View File

@ -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<void> };
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<Runtime> {
// 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<string, ServerDefinition>;
private readonly clients = new Map<string, Promise<ClientContext>>();
@ -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<ClientContext> {
// 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<void>;
finishAuth?: (authorizationCode: string) => Promise<void>;
},
session?: OAuthSession,
serverName?: string,
maxAttempts = 3
): Promise<void> {
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<string> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return session.waitForAuthorizationCode();
}
const displayName = serverName ?? 'unknown';
return new Promise<string>((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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((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.

17
src/runtime/errors.ts Normal file
View File

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

112
src/runtime/oauth.ts Normal file
View File

@ -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<void>;
finishAuth?: (authorizationCode: string) => Promise<void>;
},
session: OAuthSession | undefined,
logger: Logger,
options: { serverName?: string; maxAttempts?: number; oauthTimeoutMs?: number } = {}
): Promise<void> {
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<string> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return session.waitForAuthorizationCode();
}
const displayName = serverName ?? 'unknown';
return new Promise<string>((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);
}

168
src/runtime/transport.ts Normal file
View File

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

54
src/runtime/utils.ts Normal file
View File

@ -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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((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);
}
);
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '<adhoc>' },
};
}
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);
});
});

View File

@ -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<void>(() => {}), 500);
const expectation = expect(promise).rejects.toThrowError('Timeout');
vi.advanceTimersByTime(500);
await expectation;
vi.useRealTimers();
});
});

View File

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