refactor(cli): split generate-cli runner
This commit is contained in:
parent
d0bb8884ad
commit
af38e3be40
@ -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).
|
||||
|
||||
@ -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
201
src/cli/generate/flags.ts
Normal 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 };
|
||||
}
|
||||
112
src/cli/generate/name-utils.ts
Normal file
112
src/cli/generate/name-utils.ts
Normal 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;
|
||||
}
|
||||
27
src/cli/generate/output.ts
Normal file
27
src/cli/generate/output.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
31
src/cli/generate/server-utils.ts
Normal file
31
src/cli/generate/server-utils.ts
Normal 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>' },
|
||||
};
|
||||
}
|
||||
103
src/cli/generate/template-data.ts
Normal file
103
src/cli/generate/template-data.ts
Normal 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, `'\\''`)}'`;
|
||||
}
|
||||
1
src/cli/generate/types.ts
Normal file
1
src/cli/generate/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type CommandInput = { command: string; args?: string[] } | string;
|
||||
@ -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';
|
||||
|
||||
359
src/runtime.ts
359
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<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
17
src/runtime/errors.ts
Normal 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
112
src/runtime/oauth.ts
Normal 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
168
src/runtime/transport.ts
Normal 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
54
src/runtime/utils.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
14
tests/runtime-oauth-utils.test.ts
Normal file
14
tests/runtime-oauth-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
tests/runtime-transport.test.ts
Normal file
68
tests/runtime-transport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
tests/runtime-utils.test.ts
Normal file
31
tests/runtime-utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user