fix: respect schema string type when coercing key=value args

Closes #140

When using key=value syntax (e.g. thread_ts=1234567890.123456),
mcporter was coercing numeric-looking values to numbers before
tool invocation. This broke Slack MCP tools where thread_ts is
declared as string in the tool schema.

Added enforceSchemaStringTypes() in call-command.ts that loads
the tool schema after argument parsing and converts any coerced
number back to string if the schema declares that field as
type: string.
This commit is contained in:
Ali Hamza 2026-04-18 12:54:06 +05:00 committed by Peter Steinberger
parent 53539d90a9
commit 4d78444003
4 changed files with 583 additions and 245 deletions

View File

@ -1,17 +1,17 @@
import type { EphemeralServerSpec } from './adhoc-server.js';
import { parseLeadingCallExpression } from './call-argument-expression.js';
import type { EphemeralServerSpec } from "./adhoc-server.js";
import { parseLeadingCallExpression } from "./call-argument-expression.js";
import {
type CoercionMode,
coerceValue,
parseKeyValueToken,
shouldPromoteSelectorToCommand,
} from './call-argument-values.js';
import { buildUnknownCallFlagMessage } from './call-help.js';
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
import { CliUsageError } from './errors.js';
import { consumeOutputFormat } from './output-format.js';
import type { OutputFormat } from './output-utils.js';
import { consumeTimeoutFlag } from './timeouts.js';
} from "./call-argument-values.js";
import { buildUnknownCallFlagMessage } from "./call-help.js";
import { extractEphemeralServerFlags } from "./ephemeral-flags.js";
import { CliUsageError } from "./errors.js";
import { consumeOutputFormat } from "./output-format.js";
import type { OutputFormat } from "./output-utils.js";
import { consumeTimeoutFlag } from "./timeouts.js";
export interface CallArgsParseResult {
selector?: string;
@ -51,35 +51,53 @@ interface CallExpressionResolution {
}
const FLAG_HANDLERS: Record<string, FlagHandler> = {
'--server': handleServerFlag,
'--mcp': handleServerFlag,
'--tool': handleToolFlag,
'--timeout': handleTimeoutFlag,
'--tail-log': handleTailLogFlag,
'--save-images': handleSaveImagesFlag,
'--yes': handleNoopFlag,
'--raw-strings': handleRawStringsFlag,
'--no-coerce': handleNoCoerceFlag,
'--args': handleArgsFlag,
"--server": handleServerFlag,
"--mcp": handleServerFlag,
"--tool": handleToolFlag,
"--timeout": handleTimeoutFlag,
"--tail-log": handleTailLogFlag,
"--save-images": handleSaveImagesFlag,
"--yes": handleNoopFlag,
"--raw-strings": handleRawStringsFlag,
"--no-coerce": handleNoCoerceFlag,
"--args": handleArgsFlag,
};
export function parseCallArguments(args: string[]): CallArgsParseResult {
const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' };
const flagState: FlagParseState = { coercionMode: 'default' };
const result: CallArgsParseResult = {
args: {},
tailLog: false,
output: "auto",
};
const flagState: FlagParseState = { coercionMode: "default" };
const ephemeral = extractEphemeralServerFlags(args);
result.ephemeral = ephemeral;
result.output = consumeOutputFormat(args, {
defaultFormat: 'auto',
defaultFormat: "auto",
});
const { positional, literalPositional } = scanCallTokens(args, result, flagState);
const { callExpressionProvidedServer, callExpressionProvidedTool } = applyLeadingCallExpression(positional, result);
resolveSelectorAndTool(positional, result, callExpressionProvidedServer, callExpressionProvidedTool);
const { positional, literalPositional } = scanCallTokens(
args,
result,
flagState,
);
const { callExpressionProvidedServer, callExpressionProvidedTool } =
applyLeadingCallExpression(positional, result);
resolveSelectorAndTool(
positional,
result,
callExpressionProvidedServer,
callExpressionProvidedTool,
);
applyTrailingArguments(positional, result, flagState);
appendLiteralPositionalArguments(literalPositional, result, flagState);
return result;
}
function scanCallTokens(args: string[], result: CallArgsParseResult, state: FlagParseState): ScannedCallTokens {
function scanCallTokens(
args: string[],
result: CallArgsParseResult,
state: FlagParseState,
): ScannedCallTokens {
const positional: string[] = [];
const literalPositional: string[] = [];
let index = 0;
@ -89,7 +107,7 @@ function scanCallTokens(args: string[], result: CallArgsParseResult, state: Flag
index += 1;
continue;
}
if (token === '--') {
if (token === "--") {
literalPositional.push(...args.slice(index + 1).filter(Boolean));
break;
}
@ -98,7 +116,7 @@ function scanCallTokens(args: string[], result: CallArgsParseResult, state: Flag
index = flagHandler({ args, index, result, state });
continue;
}
if (token.startsWith('--')) {
if (token.startsWith("--")) {
throw new CliUsageError(buildUnknownCallFlagMessage(token));
}
positional.push(token);
@ -107,33 +125,48 @@ function scanCallTokens(args: string[], result: CallArgsParseResult, state: Flag
return { positional, literalPositional };
}
function applyLeadingCallExpression(positional: string[], result: CallArgsParseResult): CallExpressionResolution {
function applyLeadingCallExpression(
positional: string[],
result: CallArgsParseResult,
): CallExpressionResolution {
if (positional.length === 0) {
return { callExpressionProvidedServer: false, callExpressionProvidedTool: false };
return {
callExpressionProvidedServer: false,
callExpressionProvidedTool: false,
};
}
const rawToken = positional[0] ?? '';
const rawToken = positional[0] ?? "";
const callExpression = parseLeadingCallExpression(rawToken);
if (!callExpression) {
return { callExpressionProvidedServer: false, callExpressionProvidedTool: false };
return {
callExpressionProvidedServer: false,
callExpressionProvidedTool: false,
};
}
positional.shift();
if (callExpression.server) {
if (result.server && result.server !== callExpression.server) {
throw new Error(
`Conflicting server names: '${result.server}' from flags and '${callExpression.server}' from call expression.`
`Conflicting server names: '${result.server}' from flags and '${callExpression.server}' from call expression.`,
);
}
result.server = result.server ?? callExpression.server;
}
if (result.tool && result.tool !== callExpression.tool) {
throw new Error(
`Conflicting tool names: '${result.tool}' from flags and '${callExpression.tool}' from call expression.`
`Conflicting tool names: '${result.tool}' from flags and '${callExpression.tool}' from call expression.`,
);
}
result.tool = callExpression.tool;
Object.assign(result.args, callExpression.args);
if (callExpression.positionalArgs && callExpression.positionalArgs.length > 0) {
result.positionalArgs = [...(result.positionalArgs ?? []), ...callExpression.positionalArgs];
if (
callExpression.positionalArgs &&
callExpression.positionalArgs.length > 0
) {
result.positionalArgs = [
...(result.positionalArgs ?? []),
...callExpression.positionalArgs,
];
}
return {
callExpressionProvidedServer: Boolean(callExpression.server),
@ -145,9 +178,14 @@ function resolveSelectorAndTool(
positional: string[],
result: CallArgsParseResult,
callExpressionProvidedServer: boolean,
callExpressionProvidedTool: boolean
callExpressionProvidedTool: boolean,
): void {
if (!result.selector && positional.length > 0 && !callExpressionProvidedServer && !result.server) {
if (
!result.selector &&
positional.length > 0 &&
!callExpressionProvidedServer &&
!result.server
) {
result.selector = positional.shift();
}
if (
@ -163,15 +201,19 @@ function resolveSelectorAndTool(
if (
!result.tool &&
nextPositional !== undefined &&
!nextPositional.includes('=') &&
!nextPositional.includes(':') &&
!nextPositional.includes("=") &&
!nextPositional.includes(":") &&
!callExpressionProvidedTool
) {
result.tool = positional.shift();
}
}
function applyTrailingArguments(positional: string[], result: CallArgsParseResult, state: FlagParseState): void {
function applyTrailingArguments(
positional: string[],
result: CallArgsParseResult,
state: FlagParseState,
): void {
const trailingPositional: unknown[] = [];
for (let index = 0; index < positional.length; ) {
const token = positional[index];
@ -187,15 +229,15 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
}
index += parsed.consumed;
const value = coerceValue(parsed.rawValue, state.coercionMode);
if (parsed.key === 'tool' && !result.tool) {
if (typeof value !== 'string') {
if (parsed.key === "tool" && !result.tool) {
if (typeof value !== "string") {
throw new Error("Argument 'tool' must be a string value.");
}
result.tool = value as string;
continue;
}
if (parsed.key === 'server' && !result.server) {
if (typeof value !== 'string') {
if (parsed.key === "server" && !result.server) {
if (typeof value !== "string") {
throw new Error("Argument 'server' must be a string value.");
}
result.server = value as string;
@ -204,14 +246,17 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
result.args[parsed.key] = value;
}
if (trailingPositional.length > 0) {
result.positionalArgs = [...(result.positionalArgs ?? []), ...trailingPositional];
result.positionalArgs = [
...(result.positionalArgs ?? []),
...trailingPositional,
];
}
}
function appendLiteralPositionalArguments(
literalPositional: string[],
result: CallArgsParseResult,
state: FlagParseState
state: FlagParseState,
): void {
if (literalPositional.length === 0) {
return;
@ -223,20 +268,20 @@ function appendLiteralPositionalArguments(
}
function handleServerFlag(context: FlagHandlerContext): number {
const token = context.args[context.index] ?? '--server';
const token = context.args[context.index] ?? "--server";
context.result.server = consumeFlagValue(context.args, context.index, token);
return context.index + 2;
}
function handleToolFlag(context: FlagHandlerContext): number {
context.result.tool = consumeFlagValue(context.args, context.index, '--tool');
context.result.tool = consumeFlagValue(context.args, context.index, "--tool");
return context.index + 2;
}
function handleTimeoutFlag(context: FlagHandlerContext): number {
context.result.timeoutMs = consumeTimeoutFlag(context.args, context.index, {
flagName: '--timeout',
missingValueMessage: '--timeout requires a value (milliseconds).',
flagName: "--timeout",
missingValueMessage: "--timeout requires a value (milliseconds).",
});
// consumeTimeoutFlag removes the flag/value pair in-place; stay on the same index.
return context.index;
@ -251,8 +296,8 @@ function handleSaveImagesFlag(context: FlagHandlerContext): number {
context.result.saveImagesDir = consumeFlagValue(
context.args,
context.index,
'--save-images',
'--save-images requires a directory path.'
"--save-images",
"--save-images requires a directory path.",
);
return context.index + 2;
}
@ -262,33 +307,47 @@ function handleNoopFlag(context: FlagHandlerContext): number {
}
function handleRawStringsFlag(context: FlagHandlerContext): number {
context.state.coercionMode = 'raw-strings';
context.state.coercionMode = "raw-strings";
context.result.rawStrings = true;
return context.index + 1;
}
function handleNoCoerceFlag(context: FlagHandlerContext): number {
context.state.coercionMode = 'none';
context.state.coercionMode = "none";
context.result.rawStrings = true;
return context.index + 1;
}
function handleArgsFlag(context: FlagHandlerContext): number {
const raw = consumeFlagValue(context.args, context.index, '--args', '--args requires a JSON value.');
const raw = consumeFlagValue(
context.args,
context.index,
"--args",
"--args requires a JSON value.",
);
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch (error) {
throw new Error(`Unable to parse --args: ${(error as Error).message}`);
}
if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
throw new Error('Unable to parse --args: --args must be a JSON object.');
if (
decoded === null ||
typeof decoded !== "object" ||
Array.isArray(decoded)
) {
throw new Error("Unable to parse --args: --args must be a JSON object.");
}
Object.assign(context.result.args, decoded);
return context.index + 2;
}
function consumeFlagValue(args: string[], index: number, token: string, missingValueMessage?: string): string {
function consumeFlagValue(
args: string[],
index: number,
token: string,
missingValueMessage?: string,
): string {
const value = args[index + 1];
if (value) {
return value;

View File

@ -1,31 +1,39 @@
import { analyzeConnectionError, type ConnectionIssue } from '../error-classifier.js';
import { wrapCallResult } from '../result-utils.js';
import { type CallArgsParseResult, parseCallArguments } from './call-arguments.js';
import {
analyzeConnectionError,
type ConnectionIssue,
} from "../error-classifier.js";
import { wrapCallResult } from "../result-utils.js";
import {
type CallArgsParseResult,
parseCallArguments,
} from "./call-arguments.js";
import {
CALL_HELP_ADHOC_SERVER_LINES,
CALL_HELP_ARGUMENT_LINES,
CALL_HELP_EXAMPLE_LINES,
CALL_HELP_RUNTIME_FLAG_LINES,
} from './call-help.js';
import { prepareEphemeralServerTarget } from './ephemeral-target.js';
import { looksLikeHttpUrl, normalizeHttpUrlCandidate } from './http-utils.js';
import type { IdentifierResolution } from './identifier-helpers.js';
} from "./call-help.js";
import { prepareEphemeralServerTarget } from "./ephemeral-target.js";
import { looksLikeHttpUrl, normalizeHttpUrlCandidate } from "./http-utils.js";
import type { IdentifierResolution } from "./identifier-helpers.js";
import {
chooseClosestIdentifier,
normalizeIdentifier,
renderIdentifierResolutionMessages,
} from './identifier-helpers.js';
import { saveCallImagesIfRequested } from './image-output.js';
import { buildConnectionIssueEnvelope } from './json-output.js';
import { handleList } from './list-command.js';
import type { OutputFormat } from './output-utils.js';
import { printCallOutput, tailLogIfRequested } from './output-utils.js';
import { dumpActiveHandles } from './runtime-debug.js';
import { dimText, redText, yellowText } from './terminal.js';
import { resolveCallTimeout, withTimeout } from './timeouts.js';
import { loadToolMetadata } from './tool-cache.js';
} from "./identifier-helpers.js";
import { saveCallImagesIfRequested } from "./image-output.js";
import { buildConnectionIssueEnvelope } from "./json-output.js";
import { handleList } from "./list-command.js";
import type { OutputFormat } from "./output-utils.js";
import { printCallOutput, tailLogIfRequested } from "./output-utils.js";
import { dumpActiveHandles } from "./runtime-debug.js";
import { dimText, redText, yellowText } from "./terminal.js";
import { resolveCallTimeout, withTimeout } from "./timeouts.js";
import { loadToolMetadata } from "./tool-cache.js";
type Runtime = Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>;
type Runtime = Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>;
interface ResolvedCallTarget {
server: string;
@ -38,7 +46,10 @@ interface PreparedCallRequest extends ResolvedCallTarget {
timeoutMs: number;
}
export async function handleCall(runtime: Runtime, args: string[]): Promise<void> {
export async function handleCall(
runtime: Runtime,
args: string[],
): Promise<void> {
const prepared = await prepareCallRequest(runtime, args);
if (!prepared) {
return;
@ -52,7 +63,10 @@ export async function handleCall(runtime: Runtime, args: string[]): Promise<void
renderCallResult(invocation.result, prepared.parsed);
}
async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<PreparedCallRequest | undefined> {
async function prepareCallRequest(
runtime: Runtime,
args: string[],
): Promise<PreparedCallRequest | undefined> {
const parsed = parseCallArguments(args);
await normalizeParsedCallArguments(runtime, parsed);
const { server, tool } = await resolveServerAndTool(runtime, parsed);
@ -62,14 +76,31 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
}
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
return { parsed, server, tool, hydratedArgs, timeoutMs };
const hydratedArgs = await hydratePositionalArguments(
runtime,
server,
tool,
parsed.args,
parsed.positionalArgs,
);
const schemaAwareArgs = await enforceSchemaStringTypes(
runtime,
server,
tool,
hydratedArgs,
);
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs };
}
async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsParseResult): Promise<void> {
async function normalizeParsedCallArguments(
runtime: Runtime,
parsed: CallArgsParseResult,
): Promise<void> {
let ephemeralSpec = parsed.ephemeral ? { ...parsed.ephemeral } : undefined;
const nameHints: string[] = [];
const absorbUrlCandidate = (value: string | undefined): string | undefined => {
const absorbUrlCandidate = (
value: string | undefined,
): string | undefined => {
if (!value) {
return value;
}
@ -94,7 +125,10 @@ async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsPa
}
if (ephemeralSpec?.httpUrl && !ephemeralSpec.name && parsed.tool) {
const candidate = parsed.selector && !looksLikeHttpUrl(parsed.selector) ? parsed.selector : undefined;
const candidate =
parsed.selector && !looksLikeHttpUrl(parsed.selector)
? parsed.selector
: undefined;
if (candidate) {
nameHints.push(candidate);
parsed.selector = undefined;
@ -115,17 +149,24 @@ async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsPa
}
}
async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResult): Promise<ResolvedCallTarget> {
async function resolveServerAndTool(
runtime: Runtime,
parsed: CallArgsParseResult,
): Promise<ResolvedCallTarget> {
const target = resolveCallTarget(parsed, { allowMissingTool: true });
const server = target.server;
let tool = target.tool;
if (!server) {
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
throw new Error(
"Missing server name. Provide it via <server>.<tool> or --server.",
);
}
if (!tool) {
tool = await inferSingleToolName(runtime, server);
if (!tool) {
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
throw new Error(
"Missing tool name. Provide it via <server>.<tool> or --tool.",
);
}
}
return { server, tool };
@ -133,7 +174,7 @@ async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResul
async function invokePreparedCall(
runtime: Runtime,
prepared: PreparedCallRequest
prepared: PreparedCallRequest,
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
let invocation: { result: unknown; resolvedTool: string };
try {
@ -142,12 +183,21 @@ async function invokePreparedCall(
prepared.server,
prepared.tool,
prepared.hydratedArgs,
prepared.timeoutMs
prepared.timeoutMs,
);
} catch (error) {
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
if (prepared.parsed.output === 'json' || prepared.parsed.output === 'raw') {
const payload = buildConnectionIssueEnvelope({ server: prepared.server, tool: prepared.tool, error, issue });
const issue = maybeReportConnectionIssue(
prepared.server,
prepared.tool,
error,
);
if (prepared.parsed.output === "json" || prepared.parsed.output === "raw") {
const payload = buildConnectionIssueEnvelope({
server: prepared.server,
tool: prepared.tool,
error,
issue,
});
console.log(JSON.stringify(payload, null, 2));
process.exitCode = 1;
return undefined;
@ -162,64 +212,76 @@ function renderCallResult(result: unknown, parsed: CallArgsParseResult): void {
printCallOutput(wrapped, result, parsed.output);
saveCallImagesIfRequested(wrapped, parsed.saveImagesDir);
tailLogIfRequested(result, parsed.tailLog);
dumpActiveHandles('after call (formatted result)');
dumpActiveHandles("after call (formatted result)");
}
export function printCallHelp(): void {
const lines = [
'Usage: mcporter call <server.tool | url> [arguments] [flags]',
'',
'Selectors:',
' server.tool Use a configured server and tool (e.g., linear.list_issues).',
' https://host/mcp.tool Call a tool by full HTTP URL (auto-registers ad-hoc).',
' --server <name> Override the server name.',
' --tool <name> Override the tool name.',
'',
'Arguments:',
"Usage: mcporter call <server.tool | url> [arguments] [flags]",
"",
"Selectors:",
" server.tool Use a configured server and tool (e.g., linear.list_issues).",
" https://host/mcp.tool Call a tool by full HTTP URL (auto-registers ad-hoc).",
" --server <name> Override the server name.",
" --tool <name> Override the tool name.",
"",
"Arguments:",
...CALL_HELP_ARGUMENT_LINES,
'',
'Runtime flags:',
"",
"Runtime flags:",
...CALL_HELP_RUNTIME_FLAG_LINES,
'',
'Ad-hoc servers:',
"",
"Ad-hoc servers:",
...CALL_HELP_ADHOC_SERVER_LINES,
'',
'Examples:',
"",
"Examples:",
...CALL_HELP_EXAMPLE_LINES,
];
console.error(lines.join('\n'));
console.error(lines.join("\n"));
}
async function maybeDescribeServer(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
tool: string,
outputFormat: OutputFormat
outputFormat: OutputFormat,
): Promise<boolean> {
if (tool === 'list_tools') {
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
if (tool === "list_tools") {
console.log(
dimText(
`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`,
),
);
const listArgs = [server];
if (outputFormat === 'json') {
listArgs.push('--json');
if (outputFormat === "json") {
listArgs.push("--json");
}
await handleList(runtime, listArgs);
return true;
}
if (tool !== 'help') {
if (tool !== "help") {
return false;
}
const tools = await runtime.listTools(server, { includeSchema: false, autoAuthorize: false }).catch(() => undefined);
const tools = await runtime
.listTools(server, { includeSchema: false, autoAuthorize: false })
.catch(() => undefined);
if (!tools) {
return false;
}
const hasHelpTool = tools.some((entry) => entry.name === 'help');
const hasHelpTool = tools.some((entry) => entry.name === "help");
if (hasHelpTool) {
return false;
}
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
console.log(
dimText(
`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`,
),
);
const listArgs = [server];
if (outputFormat === 'json') {
listArgs.push('--json');
if (outputFormat === "json") {
listArgs.push("--json");
}
await handleList(runtime, listArgs);
return true;
@ -231,14 +293,14 @@ interface ResolveCallTargetOptions {
function resolveCallTarget(
parsed: CallArgsParseResult,
options: ResolveCallTargetOptions = {}
options: ResolveCallTargetOptions = {},
): { server?: string; tool?: string } {
const selector = parsed.selector;
let server = parsed.server;
let tool = parsed.tool;
if (selector && !server && selector.includes('.')) {
const [left, right] = selector.split('.', 2);
if (selector && !server && selector.includes(".")) {
const [left, right] = selector.split(".", 2);
server = left;
tool = right;
} else if (selector && !server) {
@ -248,50 +310,100 @@ function resolveCallTarget(
}
if (!server) {
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
throw new Error(
"Missing server name. Provide it via <server>.<tool> or --server.",
);
}
if (!tool && !options.allowMissingTool) {
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
throw new Error(
"Missing tool name. Provide it via <server>.<tool> or --tool.",
);
}
return { server, tool };
}
async function enforceSchemaStringTypes(
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
tool: string,
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
// Load tool schema to check declared types for each argument.
const tools = await loadToolMetadata(runtime, server, {
includeSchema: true,
}).catch(() => undefined);
if (!tools) {
return args;
}
const toolInfo = tools.find((entry) => entry.tool.name === tool);
if (!toolInfo?.tool.inputSchema) {
return args;
}
const schema = toolInfo.tool.inputSchema as {
properties?: Record<string, { type?: string }>;
};
if (!schema.properties) {
return args;
}
const corrected: Record<string, unknown> = { ...args };
for (const [key, value] of Object.entries(corrected)) {
const declared = schema.properties[key];
// If schema says this field is a string but we have a number, convert it back.
if (declared?.type === "string" && typeof value === "number") {
corrected[key] = String(value);
}
}
return corrected;
}
async function hydratePositionalArguments(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
tool: string,
namedArgs: Record<string, unknown>,
positionalArgs: unknown[] | undefined
positionalArgs: unknown[] | undefined,
): Promise<Record<string, unknown>> {
if (!positionalArgs || positionalArgs.length === 0) {
return namedArgs;
}
// We need the schema order to know which field each positional argument maps to; pull the
// tool list with schemas instead of guessing locally so optional/required order stays correct.
const tools = await loadToolMetadata(runtime, server, { includeSchema: true }).catch(() => undefined);
const tools = await loadToolMetadata(runtime, server, {
includeSchema: true,
}).catch(() => undefined);
if (!tools) {
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
throw new Error(
"Unable to load tool metadata; name positional arguments explicitly.",
);
}
const toolInfo = tools.find((entry) => entry.tool.name === tool);
if (!toolInfo) {
throw new Error(
`Unknown tool '${tool}' on server '${server}'. Double-check the name or run mcporter list ${server}.`
`Unknown tool '${tool}' on server '${server}'. Double-check the name or run mcporter list ${server}.`,
);
}
if (!toolInfo.tool.inputSchema) {
throw new Error(`Tool '${tool}' does not expose an input schema; name positional arguments explicitly.`);
throw new Error(
`Tool '${tool}' does not expose an input schema; name positional arguments explicitly.`,
);
}
const options = toolInfo.options;
if (options.length === 0) {
throw new Error(`Tool '${tool}' has no declared parameters; remove positional arguments.`);
throw new Error(
`Tool '${tool}' has no declared parameters; remove positional arguments.`,
);
}
// Respect whichever parameters the user already supplied by name so positional values only
// populate the fields that are still unset.
const remaining = options.filter((option) => !(option.property in namedArgs));
if (positionalArgs.length > remaining.length) {
throw new Error(
`Too many positional arguments (${positionalArgs.length}) supplied; only ${remaining.length} parameter${remaining.length === 1 ? '' : 's'} remain on ${tool}.`
`Too many positional arguments (${positionalArgs.length}) supplied; only ${remaining.length} parameter${remaining.length === 1 ? "" : "s"} remain on ${tool}.`,
);
}
const hydrated: Record<string, unknown> = { ...namedArgs };
@ -308,10 +420,14 @@ async function hydratePositionalArguments(
type ToolResolution = IdentifierResolution;
async function inferSingleToolName(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
server: string
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
): Promise<string | undefined> {
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
const tools = await loadToolMetadata(runtime, server, {
includeSchema: false,
});
if (tools.length !== 1) {
return undefined;
}
@ -319,38 +435,47 @@ async function inferSingleToolName(
if (!name) {
return undefined;
}
console.log(dimText(`[auto] ${server} exposes a single tool (${name}); using it.`));
console.log(
dimText(`[auto] ${server} exposes a single tool (${name}); using it.`),
);
return name;
}
async function invokeWithAutoCorrection(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
tool: string,
args: Record<string, unknown>,
timeoutMs: number
timeoutMs: number,
): Promise<{ result: unknown; resolvedTool: string }> {
// Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match.
return attemptCall(runtime, server, tool, args, timeoutMs, true);
}
async function attemptCall(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
tool: string,
args: Record<string, unknown>,
timeoutMs: number,
allowCorrection: boolean
allowCorrection: boolean,
): Promise<{ result: unknown; resolvedTool: string }> {
try {
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs);
const result = await withTimeout(
runtime.callTool(server, tool, { args, timeoutMs }),
timeoutMs,
);
return { result, resolvedTool: tool };
} catch (error) {
if (error instanceof Error && error.message === 'Timeout') {
if (error instanceof Error && error.message === "Timeout") {
const timeoutDisplay = `${timeoutMs}ms`;
await runtime.close(server).catch(() => {});
throw new Error(
`Call to ${server}.${tool} timed out after ${timeoutDisplay}. Override MCPORTER_CALL_TIMEOUT or pass --timeout to adjust.`
`Call to ${server}.${tool} timed out after ${timeoutDisplay}. Override MCPORTER_CALL_TIMEOUT or pass --timeout to adjust.`,
);
}
@ -365,12 +490,12 @@ async function attemptCall(
}
const messages = renderIdentifierResolutionMessages({
entity: 'tool',
entity: "tool",
attempted: tool,
resolution,
scope: server,
});
if (resolution.kind === 'suggest') {
if (resolution.kind === "suggest") {
if (messages.suggest) {
console.error(dimText(messages.suggest));
}
@ -379,15 +504,24 @@ async function attemptCall(
if (messages.auto) {
console.log(dimText(messages.auto));
}
return attemptCall(runtime, server, resolution.value, args, timeoutMs, false);
return attemptCall(
runtime,
server,
resolution.value,
args,
timeoutMs,
false,
);
}
}
async function maybeResolveToolName(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
runtime: Awaited<
ReturnType<(typeof import("../runtime.js"))["createRuntime"]>
>,
server: string,
attemptedTool: string,
error: unknown
error: unknown,
): Promise<ToolResolution | undefined> {
const missingName = extractMissingToolFromError(error);
if (!missingName) {
@ -399,14 +533,16 @@ async function maybeResolveToolName(
return undefined;
}
const tools = await loadToolMetadata(runtime, server, { includeSchema: false }).catch(() => undefined);
const tools = await loadToolMetadata(runtime, server, {
includeSchema: false,
}).catch(() => undefined);
if (!tools) {
return undefined;
}
const resolution = chooseClosestIdentifier(
attemptedTool,
tools.map((entry) => entry.tool.name)
tools.map((entry) => entry.tool.name),
);
if (!resolution) {
return undefined;
@ -415,7 +551,12 @@ async function maybeResolveToolName(
}
function extractMissingToolFromError(error: unknown): string | undefined {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : undefined;
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: undefined;
if (!message) {
return undefined;
}
@ -423,29 +564,38 @@ function extractMissingToolFromError(error: unknown): string | undefined {
return match?.[1];
}
function maybeReportConnectionIssue(server: string, tool: string, error: unknown): ConnectionIssue | undefined {
function maybeReportConnectionIssue(
server: string,
tool: string,
error: unknown,
): ConnectionIssue | undefined {
const issue = analyzeConnectionError(error);
const detail = summarizeIssueMessage(issue.rawMessage);
if (issue.kind === 'auth') {
if (issue.kind === "auth") {
const authCommand = `mcporter auth ${server}`;
const hint = `[mcporter] Authorization required for ${server}. Run '${authCommand}'.${detail ? ` (${detail})` : ''}`;
const hint = `[mcporter] Authorization required for ${server}. Run '${authCommand}'.${detail ? ` (${detail})` : ""}`;
console.error(yellowText(hint));
return issue;
}
if (issue.kind === 'offline') {
const hint = `[mcporter] ${server} appears offline${detail ? ` (${detail})` : ''}.`;
if (issue.kind === "offline") {
const hint = `[mcporter] ${server} appears offline${detail ? ` (${detail})` : ""}.`;
console.error(redText(hint));
return issue;
}
if (issue.kind === 'http') {
const status = issue.statusCode ? `HTTP ${issue.statusCode}` : 'an HTTP error';
const hint = `[mcporter] ${server}.${tool} responded with ${status}${detail ? ` (${detail})` : ''}.`;
if (issue.kind === "http") {
const status = issue.statusCode
? `HTTP ${issue.statusCode}`
: "an HTTP error";
const hint = `[mcporter] ${server}.${tool} responded with ${status}${detail ? ` (${detail})` : ""}.`;
console.error(dimText(hint));
return issue;
}
if (issue.kind === 'stdio-exit') {
const exit = typeof issue.stdioExitCode === 'number' ? `code ${issue.stdioExitCode}` : 'an unknown status';
const signal = issue.stdioSignal ? ` (signal ${issue.stdioSignal})` : '';
if (issue.kind === "stdio-exit") {
const exit =
typeof issue.stdioExitCode === "number"
? `code ${issue.stdioExitCode}`
: "an unknown status";
const signal = issue.stdioSignal ? ` (signal ${issue.stdioSignal})` : "";
const hint = `[mcporter] STDIO server for ${server} exited with ${exit}${signal}.`;
console.error(redText(hint));
}
@ -454,7 +604,7 @@ function maybeReportConnectionIssue(server: string, tool: string, error: unknown
function summarizeIssueMessage(message: string): string {
if (!message) {
return '';
return "";
}
const trimmed = message.trim();
if (trimmed.length <= 120) {

View File

@ -1,36 +1,50 @@
import { z } from 'zod';
import { z } from "zod";
export const ImportKindSchema = z
.enum(['cursor', 'claude-code', 'claude-desktop', 'codex', 'windsurf', 'opencode', 'vscode'])
.describe('Supported editor/client configurations to import MCP servers from');
.enum([
"cursor",
"claude-code",
"claude-desktop",
"codex",
"windsurf",
"opencode",
"vscode",
])
.describe(
"Supported editor/client configurations to import MCP servers from",
);
export type ImportKind = z.infer<typeof ImportKindSchema>;
export const DEFAULT_IMPORTS: ImportKind[] = [
'cursor',
'claude-code',
'claude-desktop',
'codex',
'windsurf',
'opencode',
'vscode',
"cursor",
"claude-code",
"claude-desktop",
"codex",
"windsurf",
"opencode",
"vscode",
];
const RawLifecycleSchema = z
.union([
z.literal('keep-alive').describe('Keep the server connection alive'),
z.literal('ephemeral').describe('Connect only when needed'),
z.literal("keep-alive").describe("Keep the server connection alive"),
z.literal("ephemeral").describe("Connect only when needed"),
z.object({
mode: z.union([z.literal('keep-alive'), z.literal('ephemeral')]).describe('Connection lifecycle mode'),
mode: z
.union([z.literal("keep-alive"), z.literal("ephemeral")])
.describe("Connection lifecycle mode"),
idleTimeoutMs: z
.number()
.int()
.positive()
.optional()
.describe('Idle timeout in milliseconds before disconnecting'),
.describe("Idle timeout in milliseconds before disconnecting"),
}),
])
.describe('Server connection lifecycle: keep-alive maintains persistent connections, ephemeral connects on-demand');
.describe(
"Server connection lifecycle: keep-alive maintains persistent connections, ephemeral connects on-demand",
);
export type RawLifecycle = z.infer<typeof RawLifecycleSchema>;
@ -38,90 +52,170 @@ const RawLoggingSchema = z
.object({
daemon: z
.object({
enabled: z.boolean().optional().describe('Enable daemon logging for this server'),
enabled: z
.boolean()
.optional()
.describe("Enable daemon logging for this server"),
})
.optional()
.describe('Daemon-specific logging configuration'),
.describe("Daemon-specific logging configuration"),
})
.optional()
.describe('Logging configuration for the server');
.describe("Logging configuration for the server");
export const RawEntrySchema = z
.object({
description: z.string().optional().describe('Human-readable description of the server'),
baseUrl: z.string().optional().describe('Base URL for HTTP/SSE transport (camelCase)'),
base_url: z.string().optional().describe('Base URL for HTTP/SSE transport (snake_case)'),
url: z.string().optional().describe('Server URL for HTTP/SSE transport'),
serverUrl: z.string().optional().describe('Server URL for HTTP/SSE transport (camelCase)'),
server_url: z.string().optional().describe('Server URL for HTTP/SSE transport (snake_case)'),
description: z
.string()
.optional()
.describe("Human-readable description of the server"),
baseUrl: z
.string()
.optional()
.describe("Base URL for HTTP/SSE transport (camelCase)"),
base_url: z
.string()
.optional()
.describe("Base URL for HTTP/SSE transport (snake_case)"),
url: z.string().optional().describe("Server URL for HTTP/SSE transport"),
serverUrl: z
.string()
.optional()
.describe("Server URL for HTTP/SSE transport (camelCase)"),
server_url: z
.string()
.optional()
.describe("Server URL for HTTP/SSE transport (snake_case)"),
command: z
.union([z.string(), z.array(z.string())])
.optional()
.describe('Command to spawn for stdio transport (string or array of arguments)'),
executable: z.string().optional().describe('Executable path for stdio transport'),
args: z.array(z.string()).optional().describe('Arguments to pass to the stdio command'),
.describe(
"Command to spawn for stdio transport (string or array of arguments)",
),
executable: z
.string()
.optional()
.describe("Executable path for stdio transport"),
args: z
.array(z.string())
.optional()
.describe("Arguments to pass to the stdio command"),
headers: z
.record(z.string(), z.string())
.optional()
.describe('HTTP headers for requests. Supports $VAR and $env:VAR placeholders'),
.describe(
"HTTP headers for requests. Supports $VAR and $env:VAR placeholders",
),
env: z
.record(z.string(), z.string())
.optional()
.describe('Environment variables for stdio commands. Supports $VAR and fallback syntax'),
auth: z.string().optional().describe('Authentication method (e.g., "oauth")'),
tokenCacheDir: z.string().optional().describe('Directory for caching OAuth tokens (camelCase)'),
token_cache_dir: z.string().optional().describe('Directory for caching OAuth tokens (snake_case)'),
clientName: z.string().optional().describe('Client identifier for server telemetry (camelCase)'),
client_name: z.string().optional().describe('Client identifier for server telemetry (snake_case)'),
oauthRedirectUrl: z.string().optional().describe('Custom OAuth redirect URL (camelCase)'),
oauth_redirect_url: z.string().optional().describe('Custom OAuth redirect URL (snake_case)'),
oauthScope: z.string().optional().describe('OAuth scope override (camelCase)'),
oauth_scope: z.string().optional().describe('OAuth scope override (snake_case)'),
.describe(
"Environment variables for stdio commands. Supports $VAR and fallback syntax",
),
auth: z
.string()
.optional()
.describe('Authentication method (e.g., "oauth")'),
tokenCacheDir: z
.string()
.optional()
.describe("Directory for caching OAuth tokens (camelCase)"),
token_cache_dir: z
.string()
.optional()
.describe("Directory for caching OAuth tokens (snake_case)"),
clientName: z
.string()
.optional()
.describe("Client identifier for server telemetry (camelCase)"),
client_name: z
.string()
.optional()
.describe("Client identifier for server telemetry (snake_case)"),
oauthRedirectUrl: z
.string()
.optional()
.describe("Custom OAuth redirect URL (camelCase)"),
oauth_redirect_url: z
.string()
.optional()
.describe("Custom OAuth redirect URL (snake_case)"),
oauthScope: z
.string()
.optional()
.describe("OAuth scope override (camelCase)"),
oauth_scope: z
.string()
.optional()
.describe("OAuth scope override (snake_case)"),
oauthCommand: z
.object({
args: z.array(z.string()).describe('Arguments for the OAuth command'),
args: z.array(z.string()).describe("Arguments for the OAuth command"),
})
.optional()
.describe('Custom OAuth command configuration for stdio servers (camelCase)'),
.describe(
"Custom OAuth command configuration for stdio servers (camelCase)",
),
oauth_command: z
.object({
args: z.array(z.string()).describe('Arguments for the OAuth command'),
args: z.array(z.string()).describe("Arguments for the OAuth command"),
})
.optional()
.describe('Custom OAuth command configuration for stdio servers (snake_case)'),
bearerToken: z.string().optional().describe('Static bearer token for authentication (camelCase)'),
bearer_token: z.string().optional().describe('Static bearer token for authentication (snake_case)'),
bearerTokenEnv: z.string().optional().describe('Environment variable name containing the bearer token (camelCase)'),
.describe(
"Custom OAuth command configuration for stdio servers (snake_case)",
),
bearerToken: z
.string()
.optional()
.describe("Static bearer token for authentication (camelCase)"),
bearer_token: z
.string()
.optional()
.describe("Static bearer token for authentication (snake_case)"),
bearerTokenEnv: z
.string()
.optional()
.describe(
"Environment variable name containing the bearer token (camelCase)",
),
bearer_token_env: z
.string()
.optional()
.describe('Environment variable name containing the bearer token (snake_case)'),
.describe(
"Environment variable name containing the bearer token (snake_case)",
),
lifecycle: RawLifecycleSchema.optional(),
logging: RawLoggingSchema,
})
.describe('MCP server definition supporting both HTTP/SSE and stdio transports');
.describe(
"MCP server definition supporting both HTTP/SSE and stdio transports",
);
export const RawConfigSchema = z
.object({
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
mcpServers: z
.record(z.string(), RawEntrySchema)
.describe("Map of server names to their configurations"),
imports: z
.array(ImportKindSchema)
.optional()
.describe('Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports'),
.describe(
"Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
),
})
.describe('mcporter configuration file schema');
.describe("mcporter configuration file schema");
export type RawEntry = z.infer<typeof RawEntrySchema>;
export type RawConfig = z.infer<typeof RawConfigSchema>;
export interface HttpCommand {
readonly kind: 'http';
readonly kind: "http";
readonly url: URL;
readonly headers?: Record<string, string>;
}
export interface StdioCommand {
readonly kind: 'stdio';
readonly kind: "stdio";
readonly command: string;
readonly args: string[];
readonly cwd: string;
@ -130,18 +224,18 @@ export interface StdioCommand {
export type CommandSpec = HttpCommand | StdioCommand;
export interface ServerSource {
readonly kind: 'local' | 'import';
readonly kind: "local" | "import";
readonly path: string;
readonly importKind?: ImportKind;
}
export type ServerLifecycle =
| {
mode: 'keep-alive';
mode: "keep-alive";
idleTimeoutMs?: number;
}
| {
mode: 'ephemeral';
mode: "ephemeral";
};
export interface ServerLoggingOptions {

View File

@ -1,12 +1,12 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import fs from "node:fs/promises";
import path from "node:path";
import {
listConfigLayerPaths as discoverConfigLayerPaths,
resolveConfigPath as discoverConfigPath,
} from './config/path-discovery.js';
import { loadConfigLayers, readConfigFile } from './config/read-config.js';
import { pathsForImport, readExternalEntries } from './config-imports.js';
import { normalizeServerEntry } from './config-normalize.js';
} from "./config/path-discovery.js";
import { loadConfigLayers, readConfigFile } from "./config/read-config.js";
import { pathsForImport, readExternalEntries } from "./config-imports.js";
import { normalizeServerEntry } from "./config-normalize.js";
import {
DEFAULT_IMPORTS,
type LoadConfigOptions,
@ -15,11 +15,11 @@ import {
RawEntrySchema,
type ServerDefinition,
type ServerSource,
} from './config-schema.js';
import { expandHome } from './env.js';
} from "./config-schema.js";
import { expandHome } from "./env.js";
export { toFileUrl } from './config-imports.js';
export { __configInternals } from './config-normalize.js';
export { toFileUrl } from "./config-imports.js";
export { __configInternals } from "./config-normalize.js";
export type {
CommandSpec,
HttpCommand,
@ -31,27 +31,45 @@ export type {
ServerLoggingOptions,
ServerSource,
StdioCommand,
} from './config-schema.js';
} from "./config-schema.js";
export async function loadServerDefinitions(options: LoadConfigOptions = {}): Promise<ServerDefinition[]> {
export async function loadServerDefinitions(
options: LoadConfigOptions = {},
): Promise<ServerDefinition[]> {
const rootDir = options.rootDir ?? process.cwd();
const layers = await loadConfigLayers(options, rootDir);
const merged = new Map<string, { raw: RawEntry; baseDir: string; source: ServerSource; sources: ServerSource[] }>();
const merged = new Map<
string,
{
raw: RawEntry;
baseDir: string;
source: ServerSource;
sources: ServerSource[];
}
>();
for (const layer of layers) {
const configuredImports = layer.config.imports;
const imports = configuredImports
? configuredImports.length === 0
? configuredImports
: [...configuredImports, ...DEFAULT_IMPORTS.filter((kind) => !configuredImports.includes(kind))]
: [
...configuredImports,
...DEFAULT_IMPORTS.filter(
(kind) => !configuredImports.includes(kind),
),
]
: DEFAULT_IMPORTS;
for (const importKind of imports) {
const candidates = pathsForImport(importKind, rootDir);
for (const candidate of candidates) {
const resolved = expandHome(candidate);
const entries = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: importKind });
const entries = await readExternalEntries(resolved, {
projectRoot: rootDir,
importKind: importKind,
});
if (!entries) {
continue;
}
@ -59,7 +77,11 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
if (merged.has(name)) {
continue;
}
const source: ServerSource = { kind: 'import', path: resolved, importKind };
const source: ServerSource = {
kind: "import",
path: resolved,
importKind,
};
const existing = merged.get(name);
// Keep the first-seen source as canonical while tracking all alternates
if (existing) {
@ -77,13 +99,18 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
}
for (const [name, entryRaw] of Object.entries(layer.config.mcpServers)) {
const source: ServerSource = { kind: 'local', path: layer.path };
const source: ServerSource = { kind: "local", path: layer.path };
const parsed = RawEntrySchema.parse(entryRaw);
const existing = merged.get(name);
// Local definitions win; stash any prior imports after the local path
if (existing) {
const sources = [source, ...existing.sources];
merged.set(name, { raw: parsed, baseDir: path.dirname(layer.path), source, sources });
merged.set(name, {
raw: parsed,
baseDir: path.dirname(layer.path),
source,
sources,
});
continue;
}
merged.set(name, {
@ -96,15 +123,20 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
}
const servers: ServerDefinition[] = [];
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
for (const [
name,
{ raw, baseDir: entryBaseDir, source, sources },
] of merged) {
servers.push(
normalizeServerEntry(name, raw, entryBaseDir, source, sources),
);
}
return servers;
}
export async function loadRawConfig(
options: LoadConfigOptions = {}
options: LoadConfigOptions = {},
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
const rootDir = options.rootDir ?? process.cwd();
const resolved = resolveConfigPath(options.configPath, rootDir);
@ -114,20 +146,23 @@ export async function loadRawConfig(
export async function listConfigLayerPaths(
options: LoadConfigOptions = {},
rootDir: string = process.cwd()
rootDir: string = process.cwd(),
): Promise<string[]> {
return await discoverConfigLayerPaths(options, rootDir);
}
export async function writeRawConfig(targetPath: string, config: RawConfig): Promise<void> {
export async function writeRawConfig(
targetPath: string,
config: RawConfig,
): Promise<void> {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
const serialized = `${JSON.stringify(config, null, 2)}\n`;
await fs.writeFile(targetPath, serialized, 'utf8');
await fs.writeFile(targetPath, serialized, "utf8");
}
export function resolveConfigPath(
configPath: string | undefined,
rootDir: string
rootDir: string,
): {
path: string;
explicit: boolean;