Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18b42a04c2 | ||
|
|
25daad40e9 | ||
|
|
550108cd68 | ||
|
|
74d992a874 | ||
|
|
f96caafe7a | ||
|
|
84e5c50c5c | ||
|
|
61340d3159 | ||
|
|
0779953cc5 | ||
|
|
b5102af1df | ||
|
|
fe87142d89 | ||
|
|
782e028abe | ||
|
|
2a9b353b21 | ||
|
|
f02bef36d2 | ||
|
|
7491ed5a85 | ||
|
|
8beee8764f | ||
|
|
c1b58296db | ||
|
|
6f3f42ca42 | ||
|
|
53747cac63 | ||
|
|
4037f0a064 | ||
|
|
37391ce70b |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@ -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'
|
||||
|
||||
2
.github/workflows/crabbox-hydrate.yml
vendored
2
.github/workflows/crabbox-hydrate.yml
vendored
@ -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 }}
|
||||
|
||||
|
||||
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@ -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
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@ -1,5 +1,35 @@
|
||||
# mcporter Changelog
|
||||
|
||||
## [0.12.3] - Unreleased
|
||||
|
||||
- Nothing yet.
|
||||
|
||||
## [0.12.2] - 2026-06-27
|
||||
|
||||
### CLI
|
||||
|
||||
- Prevent large piped CLI output from being truncated during forced shutdown, while keeping stalled readers bounded and treating broken pipes as normal shell behavior. (Issue #214, thanks @badmoo)
|
||||
- Resolve configured and ad-hoc HTTP tool selectors consistently so targeted list and call commands keep server names separate from MCP tool names. (Issue #218, thanks @xinFu3576 and @TurboTheTurtle)
|
||||
|
||||
## [0.12.1] - 2026-06-26
|
||||
|
||||
### CLI
|
||||
|
||||
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
|
||||
- Preserve replacement daemon socket and metadata ownership while a superseded daemon shuts down, preventing repeated keep-alive restarts and Chrome remote-debugging permission prompts.
|
||||
|
||||
### 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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"`).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 `--`.
|
||||
|
||||
|
||||
28
package.json
28
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcporter",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.2",
|
||||
"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
1306
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
70
src/cli.ts
70
src/cli.ts
@ -14,8 +14,52 @@ export { extractListFlags } from './cli/list-flags.js';
|
||||
export { resolveCallTimeout } from './cli/timeouts.js';
|
||||
|
||||
const FORCE_EXIT_GRACE_MS = 50;
|
||||
const STDOUT_FLUSH_TIMEOUT_MS = 2000;
|
||||
const DAEMON_FAST_PATH_SERVERS = new Set(['chrome-devtools', 'mobile-mcp', 'playwright']);
|
||||
|
||||
function handleStdioError(error: Error): void {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function installStdioErrorHandlers(): void {
|
||||
process.stdout.on('error', handleStdioError);
|
||||
process.stderr.on('error', handleStdioError);
|
||||
}
|
||||
|
||||
function flushWriteStreamForExit(stream: NodeJS.WriteStream): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!stream.writable || stream.destroyed || stream.writableEnded) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
stream.write('', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function flushStdioThenForceExit(): void {
|
||||
let exited = false;
|
||||
const exit = () => {
|
||||
if (exited) {
|
||||
return;
|
||||
}
|
||||
exited = true;
|
||||
process.exit(process.exitCode ?? 0);
|
||||
};
|
||||
const fallback = setTimeout(exit, STDOUT_FLUSH_TIMEOUT_MS);
|
||||
fallback.unref?.();
|
||||
void Promise.allSettled([flushWriteStreamForExit(process.stdout), flushWriteStreamForExit(process.stderr)]).then(
|
||||
() => {
|
||||
clearTimeout(fallback);
|
||||
exit();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAuth(
|
||||
...args: Parameters<typeof import('./cli/auth-command.js').handleAuth>
|
||||
): ReturnType<typeof import('./cli/auth-command.js').handleAuth> {
|
||||
@ -239,16 +283,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 +352,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(
|
||||
@ -350,9 +395,7 @@ async function closeRuntimeAfterCommand(
|
||||
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
|
||||
const scheduleForcedExit = () => {
|
||||
if (shouldForceExit) {
|
||||
setTimeout(() => {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, FORCE_EXIT_GRACE_MS);
|
||||
setTimeout(flushStdioThenForceExit, FORCE_EXIT_GRACE_MS);
|
||||
}
|
||||
};
|
||||
if (DEBUG_HANG) {
|
||||
@ -378,6 +421,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
|
||||
installStdioErrorHandlers();
|
||||
main().catch((error) => {
|
||||
if (error instanceof CliUsageError) {
|
||||
logError(error.message);
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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 '';
|
||||
|
||||
@ -131,10 +131,18 @@ async function normalizeParsedCallArguments(
|
||||
parsed.server = undefined;
|
||||
}
|
||||
|
||||
if (ephemeralSpec?.httpUrl && !ephemeralSpec.name && parsed.tool) {
|
||||
const candidate = parsed.selector && !looksLikeHttpUrl(parsed.selector) ? parsed.selector : undefined;
|
||||
if (candidate) {
|
||||
nameHints.push(candidate);
|
||||
if (ephemeralSpec?.httpUrl && parsed.selector && !looksLikeHttpUrl(parsed.selector)) {
|
||||
const selector = splitServerToolSelector(parsed.selector);
|
||||
if (selector) {
|
||||
if (!ephemeralSpec.name) {
|
||||
nameHints.push(selector.server);
|
||||
}
|
||||
parsed.tool ??= selector.tool;
|
||||
parsed.selector = undefined;
|
||||
} else if (parsed.tool) {
|
||||
if (!ephemeralSpec.name) {
|
||||
nameHints.push(parsed.selector);
|
||||
}
|
||||
parsed.selector = undefined;
|
||||
}
|
||||
}
|
||||
@ -316,6 +324,17 @@ function resolveCallTarget(
|
||||
return { server, tool };
|
||||
}
|
||||
|
||||
function splitServerToolSelector(selector: string): { server: string; tool: string } | undefined {
|
||||
const dotIndex = selector.indexOf('.');
|
||||
if (dotIndex <= 0 || dotIndex === selector.length - 1) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
server: selector.slice(0, dotIndex),
|
||||
tool: selector.slice(dotIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function enforceSchemaAwareArgumentTypes(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))) {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> ...',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.");
|
||||
});
|
||||
|
||||
@ -292,6 +292,133 @@ describe('CLI call execution behavior', () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls configured HTTP servers by server.tool selector', async () => {
|
||||
const { handleCall } = await cliModulePromise;
|
||||
const definition: ServerDefinition = {
|
||||
name: 'xhs',
|
||||
command: { kind: 'http', url: new URL('http://127.0.0.1:18060/mcp') },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
};
|
||||
const { runtime, callTool, registerDefinition } = createRuntimeStub(
|
||||
{
|
||||
xhs: [{ name: 'check_login_status', inputSchema: { type: 'object', properties: {} } }],
|
||||
},
|
||||
{ definitions: [definition] }
|
||||
);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleCall(runtime, ['xhs.check_login_status']);
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'xhs',
|
||||
'check_login_status',
|
||||
expect.objectContaining({
|
||||
args: {},
|
||||
})
|
||||
);
|
||||
expect(registerDefinition).not.toHaveBeenCalled();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('splits server.tool selectors before calling ad-hoc HTTP servers', async () => {
|
||||
const httpUrl = 'http://127.0.0.1:18060/mcp';
|
||||
const { handleCall } = await cliModulePromise;
|
||||
const { runtime, callTool, registerDefinition } = createRuntimeStub({});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleCall(runtime, ['xhs.check_login_status', '--http-url', httpUrl, '--allow-http']);
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'xhs',
|
||||
'check_login_status',
|
||||
expect.objectContaining({
|
||||
args: {},
|
||||
})
|
||||
);
|
||||
expect(registerDefinition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'xhs' }),
|
||||
expect.objectContaining({ overwrite: true })
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('splits server.tool selectors when ad-hoc HTTP servers also use --name', async () => {
|
||||
const httpUrl = 'http://127.0.0.1:18060/mcp';
|
||||
const { handleCall } = await cliModulePromise;
|
||||
const { runtime, callTool, registerDefinition } = createRuntimeStub({});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleCall(runtime, ['xhs.check_login_status', '--http-url', httpUrl, '--allow-http', '--name', 'xhs']);
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'xhs',
|
||||
'check_login_status',
|
||||
expect.objectContaining({
|
||||
args: {},
|
||||
})
|
||||
);
|
||||
expect(registerDefinition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'xhs' }),
|
||||
expect.objectContaining({ overwrite: true })
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses the server prefix as the ad-hoc name when --tool overrides a qualified selector', async () => {
|
||||
const httpUrl = 'http://127.0.0.1:18060/mcp';
|
||||
const { handleCall } = await cliModulePromise;
|
||||
const { runtime, callTool, registerDefinition } = createRuntimeStub({});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleCall(runtime, [
|
||||
'xhs.selector_tool',
|
||||
'--http-url',
|
||||
httpUrl,
|
||||
'--allow-http',
|
||||
'--tool',
|
||||
'check_login_status',
|
||||
]);
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'xhs',
|
||||
'check_login_status',
|
||||
expect.objectContaining({
|
||||
args: {},
|
||||
})
|
||||
);
|
||||
expect(registerDefinition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'xhs' }),
|
||||
expect.objectContaining({ overwrite: true })
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('honors explicit literal dotted tool names for named ad-hoc HTTP servers', async () => {
|
||||
const httpUrl = 'http://127.0.0.1:18060/mcp';
|
||||
const { handleCall } = await cliModulePromise;
|
||||
const { runtime, callTool } = createRuntimeStub({});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleCall(runtime, [
|
||||
'--http-url',
|
||||
httpUrl,
|
||||
'--allow-http',
|
||||
'--name',
|
||||
'xhs',
|
||||
'--tool',
|
||||
'xhs.check_login_status',
|
||||
]);
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'xhs',
|
||||
'xhs.check_login_status',
|
||||
expect.objectContaining({
|
||||
args: {},
|
||||
})
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('aborts long-running tools when the timeout elapses', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@ -448,6 +575,7 @@ function createRuntimeStub(
|
||||
runtime: Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
||||
callTool: ReturnType<typeof vi.fn>;
|
||||
listTools: ReturnType<typeof vi.fn>;
|
||||
registerDefinition: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const definitions = new Map<string, ServerDefinition>();
|
||||
for (const entry of options.definitions ?? []) {
|
||||
@ -462,6 +590,9 @@ function createRuntimeStub(
|
||||
return tools;
|
||||
});
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const registerDefinition = vi.fn().mockImplementation((definition: ServerDefinition) => {
|
||||
definitions.set(definition.name, definition);
|
||||
});
|
||||
const runtime = {
|
||||
getDefinitions: () => [...definitions.values()],
|
||||
getDefinition: vi.fn().mockImplementation((name: string) => {
|
||||
@ -471,12 +602,10 @@ function createRuntimeStub(
|
||||
}
|
||||
return definition;
|
||||
}),
|
||||
registerDefinition: vi.fn().mockImplementation((definition: ServerDefinition) => {
|
||||
definitions.set(definition.name, definition);
|
||||
}),
|
||||
registerDefinition,
|
||||
listTools,
|
||||
callTool,
|
||||
close,
|
||||
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
||||
return { runtime, callTool, listTools };
|
||||
return { runtime, callTool, listTools, registerDefinition };
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
222
tests/cli-http-selector.integration.test.ts
Normal file
222
tests/cli-http-selector.integration.test.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createServer, type Server as HttpServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import express from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const CLI_ENTRY = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
|
||||
|
||||
async function ensureDistBuilt(): Promise<void> {
|
||||
try {
|
||||
await fs.access(CLI_ENTRY);
|
||||
} catch {
|
||||
throw new Error('dist/cli.js is missing; run `pnpm build` before invoking this integration test directly.');
|
||||
}
|
||||
}
|
||||
|
||||
function runCli(args: string[], configPath: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[CLI_ENTRY, '--config', configPath, ...args],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, MCPORTER_NO_FORCE_EXIT: '1' },
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: 15_000,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`${error.message}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('mcporter HTTP selector CLI integration', () => {
|
||||
let httpServer: HttpServer;
|
||||
let baseUrl: URL;
|
||||
let tempDir: string;
|
||||
let configuredPath: string;
|
||||
let emptyPath: string;
|
||||
const observedToolNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureDistBuilt();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const server = new McpServer({ name: 'http-selector-e2e', version: '1.0.0' });
|
||||
server.registerTool(
|
||||
'check_login_status',
|
||||
{
|
||||
title: 'Check login status',
|
||||
description: 'Return a deterministic login status',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
observedToolNames.push('check_login_status');
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ loggedIn: true, observedTool: 'check_login_status' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
server.registerTool(
|
||||
'xhs.check_login_status',
|
||||
{
|
||||
title: 'Literal dotted tool',
|
||||
description: 'Prove that --tool remains literal',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
observedToolNames.push('xhs.check_login_status');
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ loggedIn: true, observedTool: 'xhs.check_login_status' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on('close', () => {
|
||||
transport.close().catch(() => {});
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
httpServer = createServer(app);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once('error', reject);
|
||||
httpServer.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${address.port}/mcp`);
|
||||
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-http-selector-e2e-'));
|
||||
configuredPath = path.join(tempDir, 'configured.json');
|
||||
emptyPath = path.join(tempDir, 'empty.json');
|
||||
await fs.writeFile(
|
||||
configuredPath,
|
||||
JSON.stringify({ imports: [], mcpServers: { xhs: { baseUrl: baseUrl.href } } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(emptyPath, JSON.stringify({ imports: [], mcpServers: {} }, null, 2), 'utf8');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('lists a configured HTTP server by name with JSON schemas', async () => {
|
||||
const result = await runCli(['list', 'xhs', '--schema', '--json'], configuredPath);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
mode: 'server',
|
||||
name: 'xhs',
|
||||
status: 'ok',
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'check_login_status' }),
|
||||
expect.objectContaining({ name: 'xhs.check_login_status' }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('routes configured and ad-hoc HTTP selectors to the intended literal tool names', async () => {
|
||||
const cases: Array<{ args: string[]; configPath: string; expectedTool: string }> = [
|
||||
{
|
||||
args: ['call', 'xhs.check_login_status', '--output', 'json'],
|
||||
configPath: configuredPath,
|
||||
expectedTool: 'check_login_status',
|
||||
},
|
||||
{
|
||||
args: ['call', 'xhs.check_login_status', '--http-url', baseUrl.href, '--allow-http', '--output', 'json'],
|
||||
configPath: configuredPath,
|
||||
expectedTool: 'check_login_status',
|
||||
},
|
||||
{
|
||||
args: ['call', 'xhs.check_login_status', '--http-url', baseUrl.href, '--allow-http', '--output', 'json'],
|
||||
configPath: emptyPath,
|
||||
expectedTool: 'check_login_status',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'call',
|
||||
'xhs.check_login_status',
|
||||
'--http-url',
|
||||
baseUrl.href,
|
||||
'--allow-http',
|
||||
'--name',
|
||||
'xhs',
|
||||
'--output',
|
||||
'json',
|
||||
],
|
||||
configPath: emptyPath,
|
||||
expectedTool: 'check_login_status',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'call',
|
||||
'xhs.selector_tool',
|
||||
'--http-url',
|
||||
baseUrl.href,
|
||||
'--allow-http',
|
||||
'--tool',
|
||||
'check_login_status',
|
||||
'--output',
|
||||
'json',
|
||||
],
|
||||
configPath: emptyPath,
|
||||
expectedTool: 'check_login_status',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'call',
|
||||
'--http-url',
|
||||
baseUrl.href,
|
||||
'--allow-http',
|
||||
'--name',
|
||||
'xhs',
|
||||
'--tool',
|
||||
'xhs.check_login_status',
|
||||
'--output',
|
||||
'json',
|
||||
],
|
||||
configPath: emptyPath,
|
||||
expectedTool: 'xhs.check_login_status',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = await runCli(testCase.args, testCase.configPath);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({ loggedIn: true, observedTool: testCase.expectedTool });
|
||||
}
|
||||
|
||||
expect(observedToolNames).toEqual(cases.map((testCase) => testCase.expectedTool));
|
||||
}, 30_000);
|
||||
});
|
||||
@ -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'/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -148,6 +148,50 @@ describe('CLI list formatting', () => {
|
||||
metadataSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('emits JSON schemas for configured HTTP servers listed by name', async () => {
|
||||
const { handleList } = await cliModulePromise;
|
||||
const toolCache = await import('../src/cli/tool-cache.js');
|
||||
const metadata = [
|
||||
{
|
||||
tool: {
|
||||
name: 'check_login_status',
|
||||
description: 'Check login status',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
methodName: 'check_login_status',
|
||||
options: [],
|
||||
},
|
||||
];
|
||||
const metadataSpy = vi.spyOn(toolCache, 'loadToolMetadata').mockResolvedValue(metadata as never);
|
||||
const definition: ServerDefinition = {
|
||||
name: 'xhs',
|
||||
command: { kind: 'http', url: new URL('http://127.0.0.1:18060/mcp') },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
};
|
||||
const registerDefinition = vi.fn();
|
||||
const runtime = {
|
||||
getDefinitions: () => [definition],
|
||||
getDefinition: () => definition,
|
||||
registerDefinition,
|
||||
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleList(runtime, ['xhs', '--schema', '--json']);
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
|
||||
expect(payload).toMatchObject({
|
||||
mode: 'server',
|
||||
name: 'xhs',
|
||||
status: 'ok',
|
||||
tools: [{ name: 'check_login_status', inputSchema: { type: 'object', properties: {} } }],
|
||||
});
|
||||
expect(registerDefinition).not.toHaveBeenCalled();
|
||||
|
||||
logSpy.mockRestore();
|
||||
metadataSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('surfaces initialize instructions in single server text and JSON output', async () => {
|
||||
const { handleList } = await cliModulePromise;
|
||||
const definition: ServerDefinition = {
|
||||
|
||||
67
tests/cli-metadata.test.ts
Normal file
67
tests/cli-metadata.test.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@ -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',
|
||||
|
||||
238
tests/cli-stdout-pipe-truncation.integration.test.ts
Normal file
238
tests/cli-stdout-pipe-truncation.integration.test.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const CLI_ENTRY = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
|
||||
const testRequire = createRequire(import.meta.url);
|
||||
const MCP_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js')).href;
|
||||
const STDIO_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/stdio.js')).href;
|
||||
|
||||
// Payload comfortably larger than the OS pipe buffer (~64KB) so that a forced
|
||||
// exit which does not wait for stdout to drain would truncate the output.
|
||||
const LARGE_TEXT_BYTES = 200_000;
|
||||
|
||||
async function ensureDistBuilt(): Promise<void> {
|
||||
try {
|
||||
await fs.access(CLI_ENTRY);
|
||||
} catch {
|
||||
throw new Error('dist/cli.js is missing; run `pnpm build` before invoking this integration test directly.');
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// Run the CLI with stdout connected to a real pipe whose reader is briefly
|
||||
// delayed before it starts draining. This is the faithful reproduction of the
|
||||
// truncation bug: on POSIX, pipe writes are async, so while the reader sleeps
|
||||
// the kernel pipe buffer fills and the remaining bytes stay queued in libuv. A
|
||||
// forced `process.exit()` that does not wait for stdout to drain then drops
|
||||
// them. The delay (500ms) stays under any reasonable flush window, so the fixed
|
||||
// binary still completes once the reader resumes.
|
||||
function runCliThroughPipe(args: string[], configPath: string, outFile: string): Promise<number> {
|
||||
const command = [
|
||||
shellQuote(process.execPath),
|
||||
shellQuote(CLI_ENTRY),
|
||||
'--config',
|
||||
shellQuote(configPath),
|
||||
...args.map(shellQuote),
|
||||
'|',
|
||||
'(sleep 0.5; cat)',
|
||||
'>',
|
||||
shellQuote(outFile),
|
||||
].join(' ');
|
||||
// Use bash with `pipefail` so a non-zero exit from the CLI (the first pipeline
|
||||
// stage) propagates, instead of being masked by the trailing `cat`.
|
||||
return new Promise((resolve) => {
|
||||
execFile('bash', ['-c', `set -o pipefail; ${command}`], { cwd: process.cwd(), env: process.env }, (error) => {
|
||||
resolve(typeof error?.code === 'number' ? error.code : 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run the CLI with stdout redirected straight to a file (synchronous writes on
|
||||
// POSIX) to obtain the complete, untruncated reference output.
|
||||
function runCliToFile(args: string[], configPath: string, outFile: string): Promise<number> {
|
||||
const command = [
|
||||
shellQuote(process.execPath),
|
||||
shellQuote(CLI_ENTRY),
|
||||
'--config',
|
||||
shellQuote(configPath),
|
||||
...args.map(shellQuote),
|
||||
'>',
|
||||
shellQuote(outFile),
|
||||
].join(' ');
|
||||
return new Promise((resolve) => {
|
||||
execFile('sh', ['-c', command], { cwd: process.cwd(), env: process.env }, (error) => {
|
||||
resolve(typeof error?.code === 'number' ? error.code : 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('mcporter broken pipe handling', () => {
|
||||
it('handles asynchronous EPIPE before runtime cleanup begins', async () => {
|
||||
await ensureDistBuilt();
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-early-epipe-'));
|
||||
const preloadPath = path.join(tempDir, 'inject-epipe.cjs');
|
||||
await fs.writeFile(
|
||||
preloadPath,
|
||||
`const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
let injected = false;
|
||||
process.stdout.write = (...args) => {
|
||||
const result = originalWrite(...args);
|
||||
if (!injected) {
|
||||
injected = true;
|
||||
process.nextTick(() => {
|
||||
process.stdout.emit(
|
||||
'error',
|
||||
Object.assign(new Error('simulated asynchronous broken pipe'), { code: 'EPIPE' })
|
||||
);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ code: number; stderr: string }>((resolve) => {
|
||||
execFile(process.execPath, ['--require', preloadPath, CLI_ENTRY, '--version'], (error, _stdout, stderr) => {
|
||||
resolve({ code: typeof error?.code === 'number' ? error.code : 0, stderr });
|
||||
});
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// POSIX-only: relies on `sh`, `sleep`, `cat` and POSIX async pipe semantics.
|
||||
describe.skipIf(process.platform === 'win32')('mcporter stdout pipe truncation on forced exit', () => {
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureDistBuilt();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-pipe-truncation-'));
|
||||
const serverScriptPath = path.join(tempDir, 'large-output-server.mjs');
|
||||
configPath = path.join(tempDir, 'config.json');
|
||||
|
||||
await fs.writeFile(
|
||||
serverScriptPath,
|
||||
`import { McpServer } from ${JSON.stringify(MCP_SERVER_MODULE)};
|
||||
import { StdioServerTransport } from ${JSON.stringify(STDIO_SERVER_MODULE)};
|
||||
|
||||
const server = new McpServer({ name: 'large-output', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'big',
|
||||
{ title: 'Big', description: 'Return a large text payload', inputSchema: {} },
|
||||
async () => ({ content: [{ type: 'text', text: 'x'.repeat(${LARGE_TEXT_BYTES}) }] })
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{ mcpServers: { 'large-output': { command: process.execPath, args: [serverScriptPath] } } },
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('does not truncate large output when stdout is a pipe', async () => {
|
||||
const args = ['call', 'large-output.big', '--output', 'json'];
|
||||
const fileOut = path.join(tempDir, 'file-output.json');
|
||||
const pipeOut = path.join(tempDir, 'pipe-output.json');
|
||||
|
||||
const fileCode = await runCliToFile(args, configPath, fileOut);
|
||||
const pipeCode = await runCliThroughPipe(args, configPath, pipeOut);
|
||||
expect(fileCode).toBe(0);
|
||||
expect(pipeCode).toBe(0);
|
||||
|
||||
const fileBytes = (await fs.readFile(fileOut)).byteLength;
|
||||
const pipeBytes = (await fs.readFile(pipeOut)).byteLength;
|
||||
|
||||
// Sanity: the reference output must exceed the kernel pipe buffer, otherwise
|
||||
// the test cannot exercise the truncation path.
|
||||
expect(fileBytes).toBeGreaterThan(70_000);
|
||||
// The bug manifested as the piped output being clamped to the pipe buffer
|
||||
// size (exactly 65536 bytes in the reported case).
|
||||
expect(pipeBytes).not.toBe(65_536);
|
||||
// The piped output must match the complete file output byte-for-byte.
|
||||
expect(pipeBytes).toBe(fileBytes);
|
||||
}, 30000);
|
||||
|
||||
it('still force-exits when stdout is piped to a consumer that never reads', async () => {
|
||||
// A consumer that keeps stdout open but never reads fills the pipe buffer
|
||||
// and blocks the drain callback. The fallback deadline must still terminate
|
||||
// the process instead of hanging indefinitely.
|
||||
const start = Date.now();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[CLI_ENTRY, '--config', configPath, 'call', 'large-output.big', '--output', 'json'],
|
||||
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
// Intentionally never consume stdout so the OS pipe buffer stays full.
|
||||
child.stdout.pause();
|
||||
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
child.on('exit', (exitCode) => resolve(exitCode ?? -1));
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(code).toBe(0);
|
||||
// Terminates via the fallback deadline (~2s) rather than hanging.
|
||||
expect(elapsed).toBeLessThan(8000);
|
||||
}, 20000);
|
||||
|
||||
it('does not crash when a piped consumer closes stdout early (EPIPE)', async () => {
|
||||
const command = [
|
||||
shellQuote(process.execPath),
|
||||
shellQuote(CLI_ENTRY),
|
||||
'--config',
|
||||
shellQuote(configPath),
|
||||
'call',
|
||||
'large-output.big',
|
||||
'--output',
|
||||
'json',
|
||||
'|',
|
||||
'head -c 100',
|
||||
'>',
|
||||
'/dev/null',
|
||||
].join(' ');
|
||||
const result = await new Promise<{ code: number; stderr: string }>((resolve) => {
|
||||
execFile(
|
||||
'bash',
|
||||
['-c', `set -o pipefail; ${command}`],
|
||||
{ cwd: process.cwd(), env: process.env },
|
||||
(error, _stdout, stderr) => {
|
||||
resolve({ code: typeof error?.code === 'number' ? error.code : 0, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/EPIPE|Unhandled 'error'/);
|
||||
}, 20000);
|
||||
});
|
||||
26
tests/cli-timeouts.test.ts
Normal file
26
tests/cli-timeouts.test.ts
Normal 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).'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
|
||||
@ -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,45 @@ 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();
|
||||
if (process.platform === 'win32') {
|
||||
await expect(fs.access(socketPath)).resolves.toBeUndefined();
|
||||
} else {
|
||||
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 }),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
18
tests/fixtures/stdio-memory-server.mjs
vendored
18
tests/fixtures/stdio-memory-server.mjs
vendored
@ -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',
|
||||
{
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
@ -747,17 +747,16 @@ describeGenerateCli('generateCli', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('accepts both kebab-case and underscore tool names for generated CLIs', async () => {
|
||||
const deepwikiRef = JSON.stringify({
|
||||
name: 'deepwiki',
|
||||
description: 'DeepWiki MCP',
|
||||
command: 'https://mcp.deepwiki.com/mcp',
|
||||
tokenCacheDir: path.join(tmpDir, 'deepwiki-cache'),
|
||||
const serverRef = JSON.stringify({
|
||||
name: 'tool-alias-test',
|
||||
description: 'Tool alias test',
|
||||
command: baseUrl.toString(),
|
||||
});
|
||||
const outputPath = path.join(tmpDir, 'deepwiki-cli.ts');
|
||||
const outputPath = path.join(tmpDir, 'tool-alias-test.ts');
|
||||
await fs.rm(outputPath, { force: true });
|
||||
|
||||
const { outputPath: renderedPath } = await generateCli({
|
||||
serverRef: deepwikiRef,
|
||||
serverRef,
|
||||
outputPath,
|
||||
runtime: 'node',
|
||||
timeoutMs: 10_000,
|
||||
@ -781,14 +780,14 @@ describeGenerateCli('generateCli', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(helpOutput).toMatch(/read-wiki-structure/);
|
||||
expect(helpOutput).not.toMatch(/read_wiki_structure/);
|
||||
expect(helpOutput).toMatch(/list-comments/);
|
||||
expect(helpOutput).not.toMatch(/list_comments/);
|
||||
|
||||
// underscore alias should still work
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
'pnpm',
|
||||
['exec', 'tsx', renderedPath, 'read_wiki_structure', '--help'],
|
||||
['exec', 'tsx', renderedPath, 'list_comments', '--help'],
|
||||
execOptions(),
|
||||
(error: import('node:child_process').ExecFileException | null) => {
|
||||
if (error) {
|
||||
@ -804,7 +803,7 @@ describeGenerateCli('generateCli', () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
'pnpm',
|
||||
['exec', 'tsx', renderedPath, 'read-wiki-structure', '--help'],
|
||||
['exec', 'tsx', renderedPath, 'list-comments', '--help'],
|
||||
execOptions(),
|
||||
(error: import('node:child_process').ExecFileException | null) => {
|
||||
if (error) {
|
||||
@ -891,3 +890,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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user