Compare commits

...

11 Commits

Author SHA1 Message Date
Peter Steinberger
fe87142d89
fix(daemon): preserve replacement socket ownership
Some checks are pending
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
2026-06-25 13:46:49 -07:00
Vincent Koc
782e028abe
test: make metadata fixture executable on Windows (#220)
Some checks are pending
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
* test: make metadata fixture executable on Windows

* test: use node executable for metadata fixture

* ci: avoid macos tsgolint crash
2026-06-25 15:00:52 +08:00
Peter Steinberger
2a9b353b21
chore(deps): update dependencies
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-06-23 20:13:48 +01:00
Vincent Koc
f02bef36d2
test: skip unusable Bun compile probes
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
2026-06-22 14:36:58 +08:00
Vincent Koc
7491ed5a85
fix: harden CLI parsing and generated artifacts 2026-06-22 14:03:17 +08:00
Vincent Koc
8beee8764f
chore(release): prepare 0.12.1
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-06-18 15:50:14 +08:00
Peter Steinberger
c1b58296db
feat: support file-backed call arguments (#213) 2026-06-18 07:54:31 +02:00
Loveacup
6f3f42ca42
fix: skip imported servers with unresolvable env placeholders (#209)
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
* fix: skip imported servers with unresolvable env placeholders

When importing MCP server configs from external sources (Cursor, Claude),
some entries reference editor-specific variables like ${workspaceFolder}
that are not available as environment variables outside the editor.

Previously, normalizeServerEntry would throw on unresolvable placeholders,
crashing the entire config loading. Now imported servers that fail to
resolve are silently skipped; locally-defined servers still fail fast.

Fixes: Cursor mcp.json with codegraph server using ${workspaceFolder}
prevents mcporter from loading any servers.

* fix(config): fall back from invalid imported duplicates

---------

Co-authored-by: Loveacup <loveacup@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 17:32:49 +08:00
Krasimir Kralev
53747cac63
fix(oauth): degrade to re-auth on corrupt credential cache instead of crashing (#208)
* fix(oauth): degrade to re-auth on corrupt credential cache instead of crashing

DirectoryPersistence.readTokens/readClientInfo/readState routed a corrupt
(truncated or malformed) cache file's JSON.parse SyntaxError straight up
through CompositePersistence, crashing the MCP connection instead of degrading
to re-auth. Every sibling reader already tolerates this: VaultPersistence
(oauth-vault.ts) catches SyntaxError to rebuild, and the daemon/server-proxy
readers wrap in catch-all. These three were the lone outliers.

Wrap the three JSON readers in a narrow helper that maps only SyntaxError to
undefined; genuine I/O faults still propagate. readJsonFile is left untouched so
the vault keeps distinguishing corrupt-from-missing for its repair path.

Fixes #207.

* fix(oauth): keep corrupt OAuth state failing closed (addresses Codex P2)

Codex review on #208 noted that making readState() corrupt-tolerant skips the
CSRF state check on the authorization callback: oauth.ts only rejects when the
stored expectedState is truthy, so a corrupt state.txt degrading to undefined
would let a mismatched/absent callback state through.

Narrow the tolerance to the credential caches only (readTokens/readClientInfo).
readState() keeps throwing on corrupt input so the OAuth flow fails closed.
Test now asserts state reads reject with SyntaxError while tokens/client degrade.

* test(oauth): preserve callback cleanup for read errors

---------

Co-authored-by: KrasimirKralev <krasi@idrobots.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 17:32:24 +08:00
Vincent Koc
4037f0a064
fix(deps): remediate Dependabot alerts (#211) 2026-06-17 10:07:12 +08:00
Peter Steinberger
37391ce70b
chore(release): start 0.12.1
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-06-10 06:52:15 +01:00
49 changed files with 1573 additions and 645 deletions

View File

@ -24,7 +24,7 @@ jobs:
os: [ubuntu-latest, macos-15, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@ -48,6 +48,11 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm --version
- run: pnpm check
if: matrix.os != 'macos-15'
- name: Check without type-aware oxlint
if: matrix.os == 'macos-15'
run: pnpm format:check && pnpm typecheck
- name: Verify generated schema is committed
if: matrix.os == 'ubuntu-latest'

View File

@ -39,7 +39,7 @@ jobs:
runs-on: [self-hosted, crabbox, openclaw, mcporter, '${{ inputs.crabbox_runner_label }}']
timeout-minutes: 120
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}

View File

@ -30,7 +30,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v6

View File

@ -1,5 +1,21 @@
# mcporter Changelog
## [0.12.1] - 2026-06-18
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
### Config
- Skip imported server entries with unresolvable editor-specific environment placeholders, and allow later valid duplicates to take effect without relaxing validation for local config. (PR #209, thanks @Loveacup)
### OAuth
- Treat corrupt cached OAuth tokens and client metadata as missing so connections can re-authenticate, while keeping corrupt callback state data fail-closed. (Issue #207, thanks @KrasimirKralev)
### Tooling / Dependencies
- Refresh development dependencies and security overrides, including Vite, esbuild, and Hono.
## [0.12.0] - 2026-06-10
### OAuth

View File

@ -143,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q
```bash
npx mcporter call chrome-devtools.take_snapshot
npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")'
npx mcporter call linear.create_comment issueId=LNR-123 body=@comment.md
npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
@ -163,6 +164,7 @@ Helpful flags:
- `--save-images <dir>` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged).
- `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings.
- `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion).
- `key=@path` / `--key @path` (on `mcporter call`) -- read a named argument as exact UTF-8 text from a file; use `@@` for a literal leading `@`.
- `--` (on `mcporter call`) -- stop flag parsing so the remaining tokens stay literal positional values, even when they start with `--`.
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
- `--status`, `--exit-code`, `--quiet` (on `mcporter list`) -- run concise server health checks through the existing list flow; `--quiet` suppresses output and exits 1 if anything checked is unhealthy.

View File

@ -71,6 +71,7 @@ Key details:
- `--key value`, `--key=value`, `key=value`, `key:value`, `key: value`, and `key:=value` all map to the same named-argument handling, so you can type whichever feels most natural for your shell. Long flag keys convert kebab-case to camelCase (`--save-to-drafts true` becomes `saveToDrafts: true`). The `:=` form is accepted as a compatibility alias for `=`.
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
- `--args -` and `--json -` read a JSON object from stdin.
- Named flag-style values can read exact UTF-8 text from a file with `key=@path` or `--key @path`. Paths resolve from the current working directory, file contents remain strings without coercion, and `key=@@literal` produces the literal value `@literal`. Function-call strings such as `body: "@literal"` remain literal.
- Bare string values supplied via long flags wrap into one-item arrays when the tool schema declares that field as an array.
- Numeric-looking `key=value` arguments are restored to their original string spelling when the tool schema declares that parameter as a string, which keeps timestamp-like IDs such as Slack `thread_ts=1234567890.123456` intact.
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).

View File

@ -53,6 +53,7 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--save-images <dir>` persist image content blocks to files under the specified directory.
- `--raw-strings` disable numeric coercion for flag-style and positional values.
- `--no-coerce` disable all flag-style/positional value coercion.
- `key=@path` / `--key @path` read a named UTF-8 string argument from a file; prefix with `@@` for a literal leading `@`.
- `--tail-log` stream tail output when the tool returns log handles.
- `--no-oauth` never start an interactive OAuth flow; use cached
tokens only while keeping eligible connections pooled.

View File

@ -30,6 +30,7 @@ mcporter call context7.resolve-library-id libraryName: value
- Use `--flag value` when you prefer long-form CLI syntax.
- Mixed forms are fine: `mcporter call linear.create_issue --team ENG title=value due: tomorrow`.
- Use `body=@comment.md` (or `--body @comment.md`) to read an exact UTF-8 string from a file; use `body=@@literal` when the value itself starts with `@`.
- `--args '{"title":"Bug"}'` still ingests JSON payloads directly.
- Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`.

View File

@ -1,6 +1,6 @@
{
"name": "mcporter",
"version": "0.12.0",
"version": "0.12.1",
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
"keywords": [
"cli",
@ -72,31 +72,31 @@
"dependencies": {
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.29.0",
"acorn": "^8.16.0",
"commander": "^14.0.3",
"es-toolkit": "^1.47.0",
"acorn": "^8.17.0",
"commander": "^15.0.0",
"es-toolkit": "^1.48.1",
"jsonc-parser": "^3.3.1",
"ora": "^9.4.0",
"rolldown": "1.0.1",
"ora": "^9.4.1",
"rolldown": "1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/estree": "^1.0.9",
"@types/express": "^5.0.6",
"@types/node": "^25.9.2",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@vitest/coverage-v8": "^4.1.8",
"@types/node": "^26.0.0",
"@typescript/native-preview": "7.0.0-dev.20260623.1",
"@vitest/coverage-v8": "^4.1.9",
"bun-types": "^1.3.14",
"cross-env": "^10.1.0",
"express": "^5.2.1",
"oxfmt": "^0.49.0",
"oxlint": "^1.69.0",
"oxlint-tsgolint": "^0.22.1",
"oxfmt": "^0.56.0",
"oxlint": "^1.71.0",
"oxlint-tsgolint": "^0.23.0",
"rimraf": "^6.1.3",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "8.0.13",
"vitest": "^4.1.8"
"vite": "8.0.16",
"vitest": "^4.1.9"
},
"devEngines": {
"runtime": [

1306
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,8 @@ onlyBuiltDependencies:
- esbuild
overrides:
body-parser: 2.2.1
esbuild: 0.28.1
hono: 4.12.25
ip-address: 10.1.1
qs: 6.15.2
vite: 8.0.13
vite: 8.0.16

View File

@ -74,16 +74,26 @@ export function metadataPathForArtifact(artifactPath: string): string {
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
// inspect command and falling back to legacy sidecar files.
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
let embeddedError: unknown;
try {
return await readMetadataFromCli(artifactPath);
} catch (error) {
embeddedError = error;
}
const legacyPath = metadataPathForArtifact(artifactPath);
try {
const buffer = await fs.readFile(legacyPath, 'utf8');
return JSON.parse(buffer) as CliArtifactMetadata;
} catch (error) {
if (isErrno(error, 'ENOENT') && embeddedError) {
throw embeddedError;
}
if (!isErrno(error, 'ENOENT')) {
throw error;
}
}
return await readMetadataFromCli(artifactPath);
throw embeddedError;
}
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {

View File

@ -239,16 +239,16 @@ export async function runCli(argv: string[]): Promise<void> {
: null;
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {
process.exitCode = inference.exitCode;
return;
}
const resolvedCommand = inference.command;
const resolvedArgs = inference.args;
let primaryError: unknown;
try {
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {
process.exitCode = inference.exitCode;
return;
}
const resolvedCommand = inference.command;
const resolvedArgs = inference.args;
if (resolvedCommand === 'list') {
if (consumeHelpTokens(resolvedArgs)) {
const { printListHelp } = await import('./cli/list-command.js');
@ -308,14 +308,15 @@ export async function runCli(argv: string[]): Promise<void> {
await importedHandleResource(runtime, resolvedArgs);
return;
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
} catch (error) {
primaryError = error;
throw error;
} finally {
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
}
async function closeRuntimeAfterCommand(

View File

@ -49,7 +49,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
};
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
const name = slugify(spec.name ?? canonical ?? inferNameFromUrl(url));
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromUrl(url));
const lifecycle = resolveLifecycle(name, undefined, command);
const definition: ServerDefinition = {
name,
@ -84,7 +84,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
cwd,
};
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
const name = slugify(spec.name ?? canonical ?? inferNameFromCommand(parts));
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromCommand(parts));
const lifecycle = resolveLifecycle(name, undefined, command);
const definition: ServerDefinition = {
name,
@ -206,6 +206,14 @@ function slugify(value: string): string {
.replace(/-{2,}/g, '-');
}
function normalizeEphemeralName(value: string): string {
const name = slugify(value);
if (!name) {
throw new Error('Ad-hoc server name must contain at least one letter or digit.');
}
return name;
}
export function splitCommandLine(input: string): string[] {
const result: string[] = [];
let current = '';

View File

@ -193,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
continue;
}
index += parsed.consumed;
const value = coerceValue(parsed.rawValue, state.coercionMode);
const { value, schemaValue } = resolveNamedArgumentValue(parsed.rawValue, state.coercionMode);
if (parsed.key === 'tool' && !result.tool) {
if (typeof value !== 'string') {
throw new Error("Argument 'tool' must be a string value.");
@ -210,7 +210,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
}
if (state.coercionMode === 'default' && typeof value === 'number') {
result.schemaStringCoercionCandidates ??= {};
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
}
result.args[parsed.key] = value;
}
@ -327,18 +327,53 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
eqIndex === -1
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
: body.slice(eqIndex + 1);
const value = coerceValue(rawValue, context.state.coercionMode);
const { value, schemaValue } = resolveNamedArgumentValue(rawValue, context.state.coercionMode);
if (context.state.coercionMode === 'default' && typeof value === 'number') {
context.result.schemaStringCoercionCandidates ??= {};
context.result.schemaStringCoercionCandidates[key] = rawValue;
context.result.schemaStringCoercionCandidates[key] = schemaValue;
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
context.result.schemaArrayCoercionCandidates ??= {};
context.result.schemaArrayCoercionCandidates[key] = rawValue;
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
}
context.result.args[key] = value;
return context.index + (eqIndex === -1 ? 2 : 1);
}
function resolveNamedArgumentValue(
rawValue: string,
coercionMode: CoercionMode
): { value: unknown; schemaValue: string } {
if (rawValue.startsWith('@@')) {
const literal = rawValue.slice(1);
return { value: literal, schemaValue: literal };
}
if (rawValue.length > 0 && rawValue.trim() === '') {
return { value: rawValue, schemaValue: rawValue };
}
if (!rawValue.startsWith('@')) {
return { value: coerceValue(rawValue, coercionMode), schemaValue: rawValue };
}
const filePath = rawValue.slice(1);
if (!filePath) {
throw new CliUsageError("Argument file reference '@' requires a path. Use '@@' for a literal leading '@'.");
}
let contents: Buffer;
try {
contents = fs.readFileSync(filePath);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new CliUsageError(`Unable to read argument file '${filePath}': ${detail}`);
}
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(contents);
return { value: text, schemaValue: text };
} catch {
throw new CliUsageError(`Argument file '${filePath}' is not valid UTF-8 text.`);
}
}
function normalizeLongFlagArgumentKey(rawKey: string): string {
if (!rawKey || rawKey.startsWith('-')) {
return '';

View File

@ -1,5 +1,6 @@
export const CALL_HELP_ARGUMENT_LINES = [
' key=value / key:value Flag-style named arguments.',
' key=@path Read a UTF-8 string value from a file; use @@ for a literal @.',
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
' --args <json> Provide a JSON object payload.',
' positional values Accepted when schema order is known.',
@ -32,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
export const CALL_HELP_EXAMPLE_LINES = [
' mcporter call linear.list_issues team=ENG limit:5',
' mcporter call linear.create_comment body=@comment.md',
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',

View File

@ -2,6 +2,7 @@ import { resolveConfigPath } from '../config/path-discovery.js';
import { parseLogLevel } from '../logging.js';
import { extractFlags } from './flag-utils.js';
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
import { parsePositiveInteger } from './timeouts.js';
export interface GlobalCliContext {
readonly globalFlags: Record<string, string | undefined>;
@ -29,8 +30,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
let oauthTimeoutOverride: number | undefined;
if (globalFlags['--oauth-timeout']) {
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
if (parsed === undefined) {
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
return { exit: true, code: 1 };
}

View File

@ -259,7 +259,7 @@ async function writeFile(targetPath: string, contents: string): Promise<void> {
function computeImportPath(fromPath: string, typesPath: string): string {
const fromDir = path.dirname(fromPath);
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
const withoutExt = relative.replace(/\.[^.]+$/, '');
const withoutExt = relative.endsWith('.d.ts') ? relative.slice(0, -5) : relative.replace(/\.[^.]+$/, '');
if (withoutExt.startsWith('.')) {
return withoutExt;
}

View File

@ -1,3 +1,5 @@
import { parsePositiveInteger } from '../timeouts.js';
export interface GeneratorCommonFlags {
runtime?: 'node' | 'bun';
timeout?: number;
@ -31,8 +33,8 @@ export function extractGeneratorFlags(args: string[], options: ExtractOptions =
if (!raw) {
throw new Error("Flag '--timeout' requires a value.");
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
const parsed = parsePositiveInteger(raw);
if (parsed === undefined) {
throw new Error('--timeout must be a positive integer.');
}
result.timeout = parsed;

View File

@ -101,6 +101,7 @@ export function renderTemplate({
tool: entry.tool,
})
);
assertUniqueGeneratedCommandNames(renderedTools);
const toolHelp = renderedTools.map((entry) => ({
name: entry.commandName,
description: entry.tool.tool.description ?? '',
@ -237,7 +238,7 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
\t}
\tconst values = value.split(',').map((entry) => entry.trim());
\tif (itemType === 'number') {
\t\treturn values.map((entry) => parseFloat(entry));
\t\treturn values.map((entry) => parseFiniteNumber(entry));
\t}
\tif (itemType === 'boolean') {
\t\treturn values.map((entry) => entry !== 'false');
@ -245,6 +246,15 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
\treturn values;
}
function parseFiniteNumber(value: string): number {
\tconst trimmed = value.trim();
\tconst parsed = Number(trimmed);
\tif (trimmed === '' || !Number.isFinite(parsed)) {
\t\tthrow new Error('Expected a finite number.');
\t}
\treturn parsed;
}
function normalizeEmbeddedServer(server: typeof embeddedServer) {
\tconst base = { ...server } as Record<string, unknown>;
\tif ((server.command as any).kind === 'http') {
@ -462,7 +472,9 @@ export function renderToolCommand(
({ option, camelCaseProp }) =>
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
)
.join(', ')}].filter((entry) => entry.value === undefined).map((entry) => entry.flag);
.join(
', '
)}].filter((entry) => entry.value === undefined || (typeof entry.value === 'string' && entry.value.trim() === '')).map((entry) => entry.flag);
\t\t\tif (missingRequired.length > 0) {
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
\t\t\t}`
@ -549,7 +561,7 @@ export const templateTestHelpers = { computeRelativeStdioCwd };
function optionParser(option: GeneratedOption): string | undefined {
switch (option.type) {
case 'number':
return '(value) => parseFloat(value)';
return '(value) => parseFiniteNumber(value)';
case 'boolean':
return "(value) => value !== 'false'";
case 'object':
@ -570,3 +582,16 @@ function optionParser(option: GeneratedOption): string | undefined {
return undefined;
}
}
function assertUniqueGeneratedCommandNames(tools: Array<{ commandName: string; tool: ToolMetadata }>): void {
const commands = new Map<string, string>();
for (const entry of tools) {
const previous = commands.get(entry.commandName);
if (previous) {
throw new Error(
`Generated command name collision '${entry.commandName}' for tools '${previous}' and '${entry.tool.tool.name}'.`
);
}
commands.set(entry.commandName, entry.tool.tool.name);
}
}

View File

@ -50,6 +50,27 @@ export function buildToolMetadata(tool: ServerToolInfo): ToolMetadata {
};
}
export function buildToolMetadataList(
tools: ServerToolInfo[],
options: { readonly sort?: boolean } = {}
): ToolMetadata[] {
const result = tools.map((tool) => buildToolMetadata(tool));
if (options.sort !== false) {
result.sort((left, right) => left.tool.name.localeCompare(right.tool.name));
}
const methods = new Map<string, string>();
for (const entry of result) {
const previous = methods.get(entry.methodName);
if (previous) {
throw new Error(
`Generated proxy method collision '${entry.methodName}' for tools '${previous}' and '${entry.tool.name}'.`
);
}
methods.set(entry.methodName, entry.tool.name);
}
return result;
}
export function buildEmbeddedSchemaMap(tools: ToolMetadata[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const entry of tools.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name))) {

View File

@ -93,6 +93,9 @@ function parseInspectFlags(args: string[]): InspectFlags {
if (!artifactPath) {
throw new Error('Usage: mcporter inspect-cli <artifact> [--json]');
}
if (args.length > 0) {
throw new Error(`Unexpected inspect-cli argument '${args[0]}'.`);
}
return { artifactPath, format };
}

View File

@ -266,5 +266,5 @@ function quoteCommandSegment(segment: string): string {
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
return segment;
}
return JSON.stringify(segment);
return `'${segment.replace(/'/g, `'\\''`)}'`;
}

View File

@ -1,4 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { inspect } from 'node:util';
import type { CallResult } from '../result-utils.js';
import { logWarn } from './logger-context.js';
@ -33,17 +34,8 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
return;
}
const candidates: string[] = [];
if (typeof result === 'string') {
const idx = result.indexOf(':');
if (idx !== -1) {
const candidate = result.slice(idx + 1).trim();
if (candidate) {
candidates.push(candidate);
}
}
}
if (result && typeof result === 'object') {
const possibleKeys = ['logPath', 'logFile', 'logfile', 'path'];
const possibleKeys = ['logPath', 'logFile', 'logfile'];
for (const key of possibleKeys) {
const value = (result as Record<string, unknown>)[key];
if (typeof value === 'string') {
@ -53,6 +45,10 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
}
for (const candidate of candidates) {
if (!path.isAbsolute(candidate)) {
logWarn(`Refusing to tail non-absolute log path: ${candidate}`);
continue;
}
if (!fs.existsSync(candidate)) {
logWarn(`Log path not found: ${candidate}`);
continue;

View File

@ -1,16 +1,21 @@
const DEFAULT_LIST_TIMEOUT_MS = 30_000;
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
export function parsePositiveInteger(raw: string | undefined): number | undefined {
if (!raw || !POSITIVE_INTEGER_PATTERN.test(raw)) {
return undefined;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : undefined;
}
// parseTimeout reads timeout values from strings while honoring defaults.
export function parseTimeout(raw: string | undefined, fallback: number): number {
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
return parsePositiveInteger(raw) ?? fallback;
}
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
@ -58,8 +63,8 @@ export function consumeTimeoutFlag(
if (!value) {
throw new Error(missingValueMessage);
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
const parsed = parsePositiveInteger(value);
if (parsed === undefined) {
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
}
args.splice(index, 2);

View File

@ -1,5 +1,5 @@
import type { ListToolsOptions, Runtime } from '../runtime.js';
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
import { buildToolMetadataList, type ToolMetadata } from './generate/tools.js';
interface LoadToolMetadataOptions {
includeSchema?: boolean;
@ -43,7 +43,7 @@ export async function loadToolMetadata(
};
const promise = runtime
.listTools(serverName, listOptions)
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
.then((tools) => buildToolMetadataList(tools, { sort: false }))
.catch((error) => {
cache?.delete(key);
throw error;

View File

@ -121,12 +121,44 @@ function validateVaultPayload(value: unknown): VaultPayload {
) {
throw new CliUsageError("Vault payload 'clientInfo' must be an object.");
}
validateOAuthTokens(record.tokens as Record<string, unknown>);
if (record.clientInfo !== undefined) {
validateOAuthClientInfo(record.clientInfo as Record<string, unknown>);
}
return {
tokens: record.tokens as OAuthTokens,
...(record.clientInfo ? { clientInfo: record.clientInfo as OAuthClientInformationMixed } : {}),
};
}
function validateOAuthTokens(tokens: Record<string, unknown>): void {
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
throw new CliUsageError('Vault payload tokens.access_token must be a non-empty string.');
}
if (typeof tokens.token_type !== 'string' || tokens.token_type.length === 0) {
throw new CliUsageError('Vault payload tokens.token_type must be a non-empty string.');
}
for (const key of ['refresh_token', 'scope'] as const) {
if (tokens[key] !== undefined && typeof tokens[key] !== 'string') {
throw new CliUsageError(`Vault payload tokens.${key} must be a string.`);
}
}
if (
tokens.expires_in !== undefined &&
(!Number.isFinite(tokens.expires_in) || typeof tokens.expires_in !== 'number')
) {
throw new CliUsageError('Vault payload tokens.expires_in must be a finite number.');
}
}
function validateOAuthClientInfo(clientInfo: Record<string, unknown>): void {
for (const [key, value] of Object.entries(clientInfo)) {
if (value !== undefined && value !== null && typeof value !== 'string') {
throw new CliUsageError(`Vault payload clientInfo.${key} must be a string.`);
}
}
}
export function printVaultHelp(): void {
const lines = [
'Usage: mcporter vault <set|clear> ...',

View File

@ -58,10 +58,16 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
continue;
}
for (const [name, rawEntry] of entries) {
const source: ServerSource = { kind: 'import', path: resolved, importKind };
const baseDir = path.dirname(resolved);
try {
normalizeServerEntry(name, rawEntry, baseDir, source, [source]);
} catch {
continue;
}
if (merged.has(name)) {
continue;
}
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) {
@ -70,7 +76,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
}
merged.set(name, {
raw: rawEntry,
baseDir: path.dirname(resolved),
baseDir,
source,
sources: [source],
});
@ -99,7 +105,13 @@ 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));
try {
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
} catch (error) {
if (source.kind !== 'import') {
throw error;
}
}
}
return servers;

View File

@ -452,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
}
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
await cleanupDaemonArtifactsIfOwned(options, process.pid);
}
export async function cleanupDaemonArtifactsIfOwned(
paths: Pick<DaemonHostOptions, 'metadataPath' | 'socketPath'>,
ownerPid: number
): Promise<void> {
// A superseded daemon may finish shutting down after its replacement has
// already rebound the same paths. Never let that old process unlink the
// replacement daemon's live socket and metadata.
const metadata = await readJsonFile<{ pid?: number; socketPath?: string }>(paths.metadataPath).catch(() => undefined);
if (metadata?.pid !== ownerPid || metadata.socketPath !== paths.socketPath) {
return;
}
if (process.platform !== 'win32') {
try {
await fs.unlink(options.socketPath);
} catch {
// ignore
}
}
try {
await fs.unlink(options.metadataPath);
} catch {
// ignore
await fs.unlink(paths.socketPath).catch(() => {});
}
await fs.unlink(paths.metadataPath).catch(() => {});
}
async function handleSocketRequest(

View File

@ -11,7 +11,7 @@ import { ensureInvocationDefaults, fetchTools, resolveServerDefinition } from '.
import { resolveRuntimeKind } from './cli/generate/runtime.js';
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
import type { ToolMetadata } from './cli/generate/tools.js';
import { buildToolMetadata, toolsTestHelpers } from './cli/generate/tools.js';
import { buildToolMetadataList, toolsTestHelpers } from './cli/generate/tools.js';
import { type CliArtifactMetadata, serializeDefinition } from './cli-metadata.js';
import { stableJsonStringify } from './cli/generate/stable-json.js';
import type { ServerDefinition } from './config.js';
@ -62,9 +62,7 @@ export async function generateCli(
: { ...baseDefinition, description: derivedDescription };
const embeddedDefinition = stripBuildSources(definition);
const serializedDefinition = serializeDefinition(embeddedDefinition);
const toolMetadata: ToolMetadata[] = tools
.map((tool) => buildToolMetadata(tool))
.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name));
const toolMetadata: ToolMetadata[] = buildToolMetadataList(tools);
const generator = await readPackageMetadata();
const baseInvocation = ensureInvocationDefaults(
{

View File

@ -152,7 +152,7 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readTokens(): Promise<OAuthTokens | undefined> {
return readJsonFile<OAuthTokens>(this.tokenPath);
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
@ -162,7 +162,7 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
@ -187,9 +187,31 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readState(): Promise<string | undefined> {
// Deliberately NOT corrupt-tolerant: a corrupt OAuth state must fail the
// flow closed. Returning undefined here would skip the CSRF state check on
// the authorization callback (see oauth.ts), so only the credential caches
// (tokens/client) degrade to re-auth.
return readJsonFile<string>(this.statePath);
}
// A present-but-corrupt credential cache (tokens/client) means "no usable
// credentials": degrade to re-auth instead of crashing the connection,
// mirroring VaultPersistence and the daemon/server-proxy readers. Genuine I/O
// faults still propagate (readJsonFile re-throws everything except ENOENT).
// OAuth state is intentionally excluded (see readState) so its CSRF check
// still fails closed on a corrupt state file.
private async readJsonOrUndefined<T>(filePath: string): Promise<T | undefined> {
try {
return await readJsonFile<T>(filePath);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
this.logger?.debug?.(`Ignoring corrupt OAuth cache file ${filePath}: ${error.message}`);
return undefined;
}
}
async saveState(value: string): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.statePath, value);

View File

@ -150,8 +150,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
// previous client registration is cached with a different redirect URI the
// auth server will reject the request with `invalid_redirect_uri`. Clear
// the stale registration so the next flow re-registers with the new URI.
// Wrapped in try/catch so persistence errors (malformed JSON, permission
// issues) close the already-bound callback server instead of leaking it.
// Wrapped in try/catch so non-recoverable persistence errors (for example,
// permission issues) close the already-bound callback server instead of leaking it.
if (usesDynamicPort) {
try {
const cachedClient = await persistence.readClientInfo();

View File

@ -1,4 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { parseCallArguments } from '../src/cli/call-arguments.js';
@ -93,6 +95,55 @@ describe('parseCallArguments', () => {
}
});
it('reads exact UTF-8 text from @path named argument values', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const payloadPath = path.join(tempDir, 'payload.txt');
fs.writeFileSync(payloadPath, 'first line\nsecond line\n', 'utf8');
try {
const parsed = parseCallArguments(['server.tool', `body=@${payloadPath}`]);
expect(parsed.args.body).toBe('first line\nsecond line\n');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('supports @path through generic long tool flags', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const payloadPath = path.join(tempDir, 'payload.txt');
fs.writeFileSync(payloadPath, 'from file', 'utf8');
try {
const parsed = parseCallArguments(['server.tool', '--body', `@${payloadPath}`]);
expect(parsed.args.body).toBe('from file');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('preserves whitespace-only generic long flag values', () => {
const parsed = parseCallArguments(['server.tool', '--body', ' ']);
expect(parsed.args.body).toBe(' ');
});
it('uses @@ to preserve a literal leading @ without reading a file', () => {
const parsed = parseCallArguments(['server.tool', 'body=@@literal']);
expect(parsed.args.body).toBe('@literal');
});
it('reports missing and non-UTF-8 argument files before transport', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const invalidPath = path.join(tempDir, 'invalid.bin');
fs.writeFileSync(invalidPath, Buffer.from([0xc3, 0x28]));
try {
expect(() => parseCallArguments(['server.tool', `body=@${path.join(tempDir, 'missing.txt')}`])).toThrow(
/Unable to read argument file/
);
expect(() => parseCallArguments(['server.tool', `body=@${invalidPath}`])).toThrow(/not valid UTF-8 text/);
expect(() => parseCallArguments(['server.tool', 'body=@'])).toThrow(/requires a path/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('throws when generic long flags are missing a value', () => {
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
});

View File

@ -34,12 +34,50 @@ async function ensureDistBuilt(): Promise<void> {
async function hasBun(): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
execFile('bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
execFile(process.env.BUN_BIN ?? 'bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
resolve(!error);
});
});
}
let bunCompileSupport: Promise<boolean> | undefined;
async function hasRunnableBunCompile(): Promise<boolean> {
bunCompileSupport ??= probeRunnableBunCompile();
return await bunCompileSupport;
}
async function probeRunnableBunCompile(): Promise<boolean> {
if (!(await hasBun())) {
return false;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bun-compile-probe-'));
const sourcePath = path.join(tempDir, 'probe.ts');
const binaryPath = path.join(tempDir, 'probe');
try {
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
const bun = process.env.BUN_BIN ?? 'bun';
const built = await new Promise<boolean>((resolve) => {
execFile(
bun,
['build', sourcePath, '--compile', '--outfile', binaryPath],
{ cwd: tempDir, env: process.env },
(error) => resolve(!error)
);
});
if (!built) {
return false;
}
return await new Promise<boolean>((resolve) => {
execFile(binaryPath, [], { cwd: tempDir, env: process.env }, (error, stdout) => {
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
});
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
async function ensureBunSupport(reason: string): Promise<boolean> {
if (process.platform === 'win32') {
console.warn(`bun not supported on Windows; skipping ${reason}.`);
@ -52,6 +90,17 @@ async function ensureBunSupport(reason: string): Promise<boolean> {
return true;
}
async function ensureRunnableBunCompile(reason: string): Promise<boolean> {
if (!(await ensureBunSupport(reason))) {
return false;
}
if (!(await hasRunnableBunCompile())) {
console.warn(`bun-compiled binaries cannot run on this runner; skipping ${reason}.`);
return false;
}
return true;
}
async function runGeneratedCli(
bundlePath: string,
args: string[],
@ -566,7 +615,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('runs "node dist/cli.js generate-cli --compile" when bun is available', async () => {
if (!(await ensureBunSupport('compile integration test'))) {
if (!(await ensureRunnableBunCompile('compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-'));
@ -616,7 +665,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('end-to-end: compiles a "bun" CLI and calls ping', async () => {
if (!(await ensureBunSupport('Bun CLI end-to-end test'))) {
if (!(await ensureRunnableBunCompile('Bun CLI end-to-end test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-bun-'));
@ -690,7 +739,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 30000);
it('runs "node dist/cli.js generate-cli --compile" using the Bun bundler by default', async () => {
if (!(await ensureBunSupport('Bun bundler compile integration test'))) {
if (!(await ensureRunnableBunCompile('Bun bundler compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-bun-'));
@ -739,7 +788,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('accepts inline stdio commands (e.g., "npx -y chrome-devtools-mcp@latest") when compiling', async () => {
if (!(await ensureBunSupport('inline stdio compile integration test'))) {
if (!(await ensureRunnableBunCompile('inline stdio compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-inline-stdio-'));
@ -884,7 +933,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
console.warn('set MCPORTER_STANDALONE_BINARY_TEST=1 to run standalone Bun release binary smoke');
return;
}
if (!(await ensureBunSupport('standalone Bun release binary smoke'))) {
if (!(await ensureRunnableBunCompile('standalone Bun release binary smoke'))) {
return;
}
await new Promise<void>((resolve, reject) => {

View File

@ -12,4 +12,10 @@ describe('inspect-cli flag parsing', () => {
it('validates explicit format values', () => {
expect(() => inspectInternals.parseInspectFlags(['--format', 'xml', 'artifact'])).toThrow(/format/);
});
it('rejects extra positional arguments', () => {
expect(() => inspectInternals.parseInspectFlags(['artifact', 'shadow'])).toThrow(
/Unexpected inspect-cli argument 'shadow'/
);
});
});

View File

@ -0,0 +1,67 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { metadataPathForArtifact, readCliMetadata } from '../src/cli-metadata.js';
describe('readCliMetadata', () => {
it('prefers embedded metadata over stale sidecar metadata', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-metadata-'));
const artifact = path.join(tempDir, process.platform === 'win32' ? 'artifact.exe' : 'artifact');
const embedded = metadataPayload('embedded');
const sidecar = metadataPayload('sidecar');
const previousEmbeddedMetadata = process.env.MCPORTER_TEST_EMBEDDED_METADATA;
const previousNodeOptions = process.env.NODE_OPTIONS;
process.env.MCPORTER_TEST_EMBEDDED_METADATA = JSON.stringify(embedded);
if (process.platform === 'win32') {
const preload = path.join(tempDir, 'inspect-preload.cjs');
await fs.copyFile(process.execPath, artifact);
await fs.writeFile(
preload,
'console.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA); process.exit(0);\n',
'utf8'
);
const requirePath = preload.replaceAll(path.sep, path.posix.sep);
process.env.NODE_OPTIONS = `${previousNodeOptions ? `${previousNodeOptions} ` : ''}--require ${requirePath}`;
} else {
const artifactContent = '#!/usr/bin/env node\nconsole.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA);\n';
await fs.writeFile(artifact, artifactContent, 'utf8');
await fs.chmod(artifact, 0o755);
}
await fs.writeFile(metadataPathForArtifact(artifact), JSON.stringify(sidecar), 'utf8');
try {
await expect(readCliMetadata(artifact)).resolves.toMatchObject({
server: { name: 'embedded' },
});
} finally {
if (previousEmbeddedMetadata === undefined) {
delete process.env.MCPORTER_TEST_EMBEDDED_METADATA;
} else {
process.env.MCPORTER_TEST_EMBEDDED_METADATA = previousEmbeddedMetadata;
}
if (previousNodeOptions === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = previousNodeOptions;
}
}
});
});
function metadataPayload(name: string) {
return {
schemaVersion: 1,
generatedAt: '1970-01-01T00:00:00.000Z',
generator: { name: 'mcporter', version: 'test' },
server: {
name,
definition: {
name,
command: { kind: 'stdio' as const, command: 'node', args: [], cwd: process.cwd() },
},
},
artifact: { path: '', kind: 'template' as const },
invocation: { runtime: 'node' as const, timeoutMs: 30_000, minify: false },
};
}

View File

@ -52,6 +52,13 @@ describe('mcporter --oauth-timeout flag', () => {
createRuntimeSpy.mockRestore();
});
it('rejects malformed --oauth-timeout values', async () => {
const { runCli } = await import('../src/cli.js');
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(runCli(['--oauth-timeout', '5000abc', 'list'])).rejects.toThrow(/process\.exit/);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('positive integer'));
});
it('returns once runtime.listTools surfaces an OAuth timeout error', async () => {
const definition = {
name: 'fake',

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { consumeTimeoutFlag, parseTimeout } from '../src/cli/timeouts.js';
describe('CLI timeout parsing', () => {
it('accepts positive integer millisecond values', () => {
expect(parseTimeout('2500', 30_000)).toBe(2_500);
const args = ['--timeout', '7500', 'server'];
expect(consumeTimeoutFlag(args, 0)).toBe(7_500);
expect(args).toEqual(['server']);
});
it('falls back for non-positive and partially numeric environment values', () => {
for (const value of ['0', '-1', '1s', '10abc', '100.5']) {
expect(parseTimeout(value, 30_000)).toBe(30_000);
}
});
it('rejects non-positive and partially numeric CLI flag values', () => {
for (const value of ['0', '-1', '1s', '10abc', '100.5']) {
expect(() => consumeTimeoutFlag(['--timeout', value], 0)).toThrow(
'--timeout must be a positive integer (milliseconds).'
);
}
});
});

View File

@ -223,6 +223,48 @@ describe('config imports', () => {
}
});
it('falls back to a later imported duplicate when an earlier import has unresolved placeholders', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-import-fallback-'));
try {
const configPath = path.join(tempRoot, 'config', 'mcporter.json');
const cursorPath = path.join(tempRoot, '.cursor', 'mcp.json');
const claudePath = path.join(ensureFakeHomeDir(), '.claude', 'settings.json');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {}, imports: ['cursor', 'claude-code'] }));
fs.writeFileSync(
cursorPath,
JSON.stringify({
mcpServers: {
shared: { command: 'cursor-mcp', args: ['${workspaceFolder}'] },
},
})
);
fs.writeFileSync(
claudePath,
JSON.stringify({
mcpServers: {
shared: { command: 'claude-mcp', args: ['--usable'] },
},
})
);
const servers = await loadServerDefinitions({ configPath, rootDir: tempRoot });
const shared = servers.find((server) => server.name === 'shared');
expect(shared?.command.kind).toBe('stdio');
expect(shared?.command.kind === 'stdio' ? shared.command.command : undefined).toBe('claude-mcp');
expect(shared?.source).toEqual({
kind: 'import',
path: claudePath,
importKind: 'claude-code',
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('loads Claude project-scoped servers without treating metadata as servers', async () => {
const homeDir = ensureFakeHomeDir();
const claudeDir = path.join(homeDir, '.claude');

View File

@ -5,7 +5,12 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { __testProcessRequest, isDaemonResponding, metadataMatches } from '../src/daemon/host.js';
import {
__testProcessRequest,
cleanupDaemonArtifactsIfOwned,
isDaemonResponding,
metadataMatches,
} from '../src/daemon/host.js';
import type { DaemonRequest } from '../src/daemon/protocol.js';
import type { Runtime } from '../src/runtime.js';
@ -246,6 +251,41 @@ describe('metadataMatches', () => {
});
});
describe('daemon artifact cleanup', () => {
let dir: string;
let metadataPath: string;
let socketPath: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cleanup-'));
metadataPath = path.join(dir, 'daemon.json');
socketPath = path.join(dir, 'daemon.sock');
await fs.writeFile(socketPath, 'socket', 'utf8');
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it('removes artifacts still owned by the stopping daemon', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath }), 'utf8');
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
await expect(fs.access(metadataPath)).rejects.toThrow();
await expect(fs.access(socketPath)).rejects.toThrow();
});
it('preserves artifacts replaced by a newer daemon', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9876, socketPath }), 'utf8');
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
await expect(fs.access(metadataPath)).resolves.toBeUndefined();
await expect(fs.access(socketPath)).resolves.toBeUndefined();
});
});
function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
return {
callTool: vi.fn().mockResolvedValue({ ok: true }),

View File

@ -101,6 +101,10 @@ describe('emit-ts templates', () => {
expect(source).toContain('wrapCallResult');
expect(source).toContain('proxy.listComments');
});
it('does not leave a .d suffix when importing generated declaration files', () => {
expect(emitTsTestInternals.computeImportPath('/tmp/client.ts', '/tmp/client.d.ts')).toBe('./client');
});
});
describe('handleEmitTs', () => {

View File

@ -32,6 +32,24 @@ server.registerTool(
}
);
server.registerTool(
'echo_text',
{
title: 'Echo Text',
description: 'Return the provided text unchanged',
inputSchema: {
text: z.string(),
},
outputSchema: {
text: z.string(),
},
},
async ({ text }) => ({
content: [{ type: 'text', text }],
structuredContent: { text },
})
);
server.registerTool(
'list_entities',
{

View File

@ -4,6 +4,7 @@ import {
buildFallbackLiteral,
buildPlaceholder,
buildToolMetadata,
buildToolMetadataList,
extractOptions,
getDescriptorDefault,
getDescriptorDescription,
@ -45,6 +46,15 @@ describe('generate helpers', () => {
}
});
it('rejects generated proxy method collisions', () => {
expect(() =>
buildToolMetadataList([
{ name: 'some-tool', inputSchema: undefined, outputSchema: undefined },
{ name: 'some_tool', inputSchema: undefined, outputSchema: undefined },
])
).toThrow(/Generated proxy method collision 'someTool'/);
});
it('extracts detailed option information', () => {
const options = extractOptions(sampleTool);
const first = options.find((option) => option.property === 'firstValue');

View File

@ -203,9 +203,9 @@ describeGenerateCli('generateCli', () => {
});
await fs.mkdir(path.join(tmpDir, 'schema-cache'), { recursive: true });
const exec = await import('node:child_process');
const bunAvailable = await hasBun(exec);
const bunAvailable = await hasRunnableBunCompile(exec);
if (!bunAvailable) {
console.warn('bun is not available on this runner; skipping compilation checks.');
console.warn('bun-compiled binaries cannot run on this runner; skipping compilation checks.');
return;
}
await ensureDistBuilt();
@ -891,3 +891,38 @@ async function hasBun(exec: typeof import('node:child_process')) {
});
});
}
let bunCompileSupport: Promise<boolean> | undefined;
async function hasRunnableBunCompile(exec: typeof import('node:child_process')) {
bunCompileSupport ??= probeRunnableBunCompile(exec);
return await bunCompileSupport;
}
async function probeRunnableBunCompile(exec: typeof import('node:child_process')) {
if (!(await hasBun(exec))) {
return false;
}
const tempDir = await fs.mkdtemp(path.join(tmpDir, 'bun-compile-probe-'));
const sourcePath = path.join(tempDir, 'probe.ts');
const binaryPath = path.join(tempDir, 'probe');
try {
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
const bun = process.env.BUN_BIN ?? 'bun';
const built = await new Promise<boolean>((resolve) => {
exec.execFile(bun, ['build', sourcePath, '--compile', '--outfile', binaryPath], execOptions(), (error) =>
resolve(!error)
);
});
if (!built) {
return false;
}
return await new Promise<boolean>((resolve) => {
exec.execFile(binaryPath, [], execOptions(), (error, stdout) => {
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
});
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}

View File

@ -137,6 +137,17 @@ describe('list output helpers', () => {
expect(entry.authCommand).toBe(buildAuthCommandHint(definition));
});
it('shell-quotes auth hints for stdio commands', () => {
const hint = buildAuthCommandHint({
name: 'unsafe',
command: { kind: 'stdio', command: 'node', args: ['server.js', '--name', "$(touch bad)'"], cwd: process.cwd() },
auth: 'oauth',
source: { kind: 'local', path: '<adhoc>' },
});
expect(hint).toContain('mcporter auth --stdio node server.js --name ');
expect(hint).toContain("'$(touch bad)'\\'''");
});
it('exposes source list in JSON only when includeSources is true', () => {
const withSources: ServerDefinition = {
...definition,

View File

@ -44,6 +44,30 @@ describe('oauth persistence', () => {
await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('degrades corrupt credential caches to undefined but keeps corrupt OAuth state failing closed', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-corrupt-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
// Truncated / malformed credential files, e.g. an interrupted write.
await fs.writeFile(path.join(cacheDir, 'tokens.json'), '{ "access_token": "part');
await fs.writeFile(path.join(cacheDir, 'client.json'), 'not json at all');
await fs.writeFile(path.join(cacheDir, 'state.txt'), '"unterminated');
const persistence = await buildOAuthPersistence(mkDef('service', cacheDir));
// Corrupt credential caches must read as "no usable credentials" (degrade to
// re-auth), not surface a SyntaxError that crashes the connection.
expect(await persistence.readTokens()).toBeUndefined();
expect(await persistence.readClientInfo()).toBeUndefined();
// OAuth state must NOT silently degrade: returning undefined would skip the
// CSRF state check on callback (oauth.ts). It must fail closed.
await expect(persistence.readState()).rejects.toThrow(SyntaxError);
});
it('prefers explicit tokenCacheDir before vault when reading tokens', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
tempRoots.push(tmp);

View File

@ -148,10 +148,9 @@ describe('FileOAuthClientProvider session lifecycle', () => {
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('clearing stale client registration'));
});
it('closes the callback server when stale-client reads throw', async () => {
it('closes the callback server when stale-client reads have I/O errors', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
await fs.writeFile(path.join(tokenCacheDir, 'client.json'), '{not-valid-json', 'utf8');
const definition: ServerDefinition = {
name: 'test-oauth-read-failure',
description: 'Test OAuth server',
@ -165,6 +164,8 @@ describe('FileOAuthClientProvider session lifecycle', () => {
error: vi.fn(),
};
const readError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError);
const originalCreateServer = http.createServer.bind(http);
const createdServers: http.Server[] = [];
const createServerSpy = vi.spyOn(http, 'createServer').mockImplementation((...args) => {
@ -174,11 +175,12 @@ describe('FileOAuthClientProvider session lifecycle', () => {
});
try {
await expect(createOAuthSession(definition, logger)).rejects.toThrow(SyntaxError);
await expect(createOAuthSession(definition, logger)).rejects.toMatchObject({ code: 'EACCES' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(createdServers).toHaveLength(1);
expect(createdServers[0]?.listening).toBe(false);
} finally {
readFileSpy.mockRestore();
createServerSpy.mockRestore();
}
});

View File

@ -117,4 +117,20 @@ describe('stdio MCP servers (filesystem + memory)', () => {
},
20000
);
memoryTest(
'passes multiline @path argument values unchanged to a stdio MCP server',
async () => {
const payloadPath = path.join(tempDir, 'multiline.txt');
const payload = 'first line\nsecond line\n';
await fs.writeFile(payloadPath, payload, 'utf8');
const callResult = await runCli(
['call', 'memory-test.echo_text', '--output', 'json', `text=@${payloadPath}`],
configPath
);
expect(callResult.stderr).toBe('');
expect(JSON.parse(callResult.stdout)).toMatchObject({ text: payload });
},
20000
);
});

View File

@ -1,6 +1,7 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { templateTestHelpers } from '../src/cli/generate/template.js';
import { renderTemplate, templateTestHelpers } from '../src/cli/generate/template.js';
import type { CliArtifactMetadata } from '../src/cli-metadata.js';
import type { ServerDefinition } from '../src/config.js';
const { computeRelativeStdioCwd } = templateTestHelpers;
@ -49,3 +50,103 @@ describe('computeRelativeStdioCwd', () => {
expect(computeRelativeStdioCwd(stdioDef({ cwd: 'relative-dir' }), outputPath)).toBe(expected);
});
});
describe('renderTemplate', () => {
it('rejects sanitized command name collisions before emitting a broken CLI', () => {
expect(() =>
renderTemplate({
runtimeKind: 'node',
timeoutMs: 30_000,
definition: stdioDef(),
serverName: 'demo',
generator: { name: 'mcporter', version: 'test' },
metadata: metadataFor('demo'),
tools: [
{
tool: { name: 'foo/bar', inputSchema: undefined, outputSchema: undefined },
methodName: 'fooSlash',
options: [],
},
{
tool: { name: 'foo_bar', inputSchema: undefined, outputSchema: undefined },
methodName: 'fooUnderscore',
options: [],
},
],
})
).toThrow(/Generated command name collision 'foo-bar'/);
});
it('emits strict numeric parsing and empty required-string validation', () => {
const source = renderTemplate({
runtimeKind: 'node',
timeoutMs: 30_000,
definition: stdioDef(),
serverName: 'demo',
generator: { name: 'mcporter', version: 'test' },
metadata: metadataFor('demo'),
tools: [
{
tool: {
name: 'sum',
inputSchema: {
type: 'object',
properties: {
count: { type: 'number' },
coords: { type: 'array', items: { type: 'number' } },
name: { type: 'string' },
},
required: ['name'],
},
outputSchema: undefined,
},
methodName: 'sum',
options: [
{
property: 'count',
cliName: 'count',
required: false,
type: 'number',
placeholder: '<count:number>',
},
{
property: 'coords',
cliName: 'coords',
required: false,
type: 'array',
arrayItemType: 'number',
placeholder: '<coords:value1,value2>',
},
{
property: 'name',
cliName: 'name',
required: true,
type: 'string',
placeholder: '<name>',
},
],
},
],
});
expect(source).toContain('parseFiniteNumber');
expect(source).not.toContain('parseFloat');
expect(source).toContain("typeof entry.value === 'string' && entry.value.trim() === ''");
});
});
function metadataFor(serverName: string): CliArtifactMetadata {
return {
schemaVersion: 1,
generatedAt: '1970-01-01T00:00:00.000Z',
generator: { name: 'mcporter', version: 'test' },
server: {
name: serverName,
definition: {
name: serverName,
command: { kind: 'stdio' as const, command: 'node', args: [], cwd: process.cwd() },
},
},
artifact: { path: '', kind: 'template' as const },
invocation: { runtime: 'node' as const, timeoutMs: 30_000, minify: false },
};
}