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:
parent
53539d90a9
commit
4d78444003
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user