Force CLI exit after cleanup and patch stdio transport

This commit is contained in:
Peter Steinberger 2025-11-06 16:37:32 +00:00
parent 216280cdb3
commit 05478a81ac
8 changed files with 464 additions and 205 deletions

View File

@ -3,6 +3,7 @@
## [Unreleased]
- Added configurable log levels (`--log-level` flag and `MCPORTER_LOG_LEVEL`) with a default of `warn`, and promoted transport fallbacks to warnings so important failures still surface at the quieter default.
- Forced the CLI to exit cleanly after shutdown (new `MCPORTER_NO_FORCE_EXIT` opt-out) and patched `StdioClientTransport` locally so stdio MCP servers do not leave Node handles hanging. Documented the tmux workflow for hang debugging.
## [0.2.0] - 2025-11-06

336
README.md
View File

@ -1,234 +1,173 @@
# mcporter 🧳
_TypeScript runtime + CLI generator for the Model Context Protocol._
_TypeScript runtime, CLI, and code-generation toolkit for the Model Context Protocol._
`mcporter` packages an ergonomic, composable toolkit that works equally well for command-line operators and long-running agents.
mcporter helps you lean into the "code execution" workflows highlighted in Anthropic's **Code Execution with MCP** guidance: discover the MCP servers already configured on your system, call them directly, compose richer automations in TypeScript, and mint single-purpose CLIs when you need to share a tool. All of that works out of the box -- no boilerplate, no schema spelunking.
## Features
## Key Capabilities
- **Zero-config discovery.** `createRuntime()` loads `config/mcporter.json`, merges Cursor/Claude/Codex imports, expands `${ENV}` placeholders, and pools connections so you can reuse transports across multiple calls.
- **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration.
- **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, and `.content()` helpers.
- **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface.
## Quick Start
mcporter auto-discovers the MCP servers you already configured in Cursor, Claude Code/Desktop, Codex, or local overrides. You can try it immediately with `npx`--no installation required.
### List your MCP servers
```bash
npx mcporter list
npx mcporter list context7 --schema
```
### Context7: fetch docs (no auth required)
```bash
npx mcporter call context7.resolve-library-id libraryName=react
npx mcporter call context7.get-library-docs context7CompatibleLibraryID=/websites/react_dev topic=hooks
```
### Linear: search documentation (requires `LINEAR_API_KEY`)
```bash
LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation query="automations"
```
### Chrome DevTools: snapshot the current tab
```bash
npx mcporter call chrome-devtools.take_snapshot
```
Helpful flags:
- `--config <path>` -- custom config file (defaults to `./config/mcporter.json`).
- `--root <path>` -- working directory for stdio commands.
- `--log-level <debug|info|warn|error>` -- adjust verbosity (respects `MCPORTER_LOG_LEVEL`).
- `--tail-log` -- stream the last 20 lines of any log files referenced by the tool response.
- For OAuth-protected servers such as `vercel`, run `npx mcporter auth vercel` once to complete login.
Timeouts default to 30 s; override with `MCPORTER_LIST_TIMEOUT` or `MCPORTER_CALL_TIMEOUT` when you expect slow startups.
- **Zero-config CLI** `npx mcporter list` and `npx mcporter call` get you from install to tool execution quickly, with niceties such as `--tail-log`.
- **Composable runtime API** `createRuntime()` pools connections, handles retries, and exposes a typed interface for Bun/Node agents.
- **OAuth support** automatic browser launches, local callback server, and token persistence under `~/.mcporter/<server>/` (compatible with existing `token_cache_dir` overrides).
- **Structured configuration** reads `config/mcporter.json`, automatically merges Cursor/Claude/Codex configs when present, and expands `${ENV}` placeholders, stdio wrappers, and headers in a predictable way.
- **Integration-ready** ships with unit and integration tests (including a streamable HTTP fixture) plus GitHub Actions CI, so changes remain trustworthy.
## Installation
### npm / pnpm / yarn
### Run instantly with `npx`
```bash
npx mcporter list
```
### Add to your project
```bash
pnpm add mcporter
# or
yarn add mcporter
# or
npm install mcporter
```
### Homebrew (macOS arm64)
### Homebrew (planned for mcporter 0.3.0)
```bash
brew tap steipete/tap
brew install steipete/tap/mcporter
```
> Note: Homebrew installation currently ships the Bun-compiled arm64 binary. Intel Macs should use the npm install method or Rosetta.
> The tap publishes alongside mcporter 0.3.0. Until then, use `npx` or `pnpm add`.
## Quick Start
```ts
import { createRuntime } from "mcporter";
const runtime = await createRuntime({ configPath: "./config/mcporter.json" });
const tools = await runtime.listTools("chrome-devtools");
const screenshot = await runtime.callTool("chrome-devtools", "take_screenshot", {
args: { url: "https://x.com" },
});
await runtime.close();
```
Prefer `createRuntime` when you plan to issue multiple calls—the runtime caches connections, handles OAuth refreshes, and closes transports when you call `runtime.close()`.
An end-to-end example lives in `examples/context7-headlines.ts`; it resolves a library via Context7, fetches documentation, and prints the markdown headings. Run it with:
```
pnpm exec tsx examples/context7-headlines.ts
```
Need a quick, single invocation?
## One-shot calls from code
```ts
import { callOnce } from "mcporter";
const result = await callOnce({
server: "firecrawl",
toolName: "crawl",
args: { url: "https://anthropic.com" },
configPath: "./config/mcporter.json",
server: "firecrawl",
toolName: "crawl",
args: { url: "https://anthropic.com" },
});
console.log(result); // raw MCP envelope
```
## CLI Reference
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex imports), handles OAuth prompts, and closes transports when it finishes. It is ideal for manual runs or wiring mcporter directly into an agent tool hook.
```
npx mcporter list # list all configured servers (non-blocking auth detection)
npx mcporter list vercel --schema # show tool signatures + schemas
npx mcporter auth vercel --reset # clear cached tokens, then walk through OAuth
npx mcporter auth vercel # pre-authorize OAuth flows without listing tools
npx mcporter call linear.searchIssues owner=ENG status=InProgress
npx mcporter call signoz.query --tail-log # print the tail of returned log files
npx mcporter inspect-cli scripts/cli/vercel # show metadata + stored generate-cli command
npx mcporter regenerate-cli scripts/cli/vercel # rebuild a CLI artifact using saved metadata
## Compose Automations with the Runtime
# Local scripts for workspace automation
pnpm mcporter:list # alias for mcporter list
pnpm mcporter:call chrome-devtools.getTabs --tail-log
pnpm mcporter:auth vercel --reset # same as mcporter auth --reset
pnpm mcporter:auth vercel # same as mcporter auth
```ts
import { createRuntime } from "mcporter";
const runtime = await createRuntime();
const tools = await runtime.listTools("context7");
const result = await runtime.callTool("context7", "resolve-library-id", {
args: { libraryName: "react" },
});
console.log(result); // raw MCP envelope
await runtime.close(); // shuts down transports and OAuth sessions
```
Generated artifacts drop a companion `<artifact>.metadata.json` that records the generator version, resolved server definition, and the exact `generate-cli` flags that produced the file. Use `mcporter inspect-cli <artifact>` to review the metadata (or emit JSON with `--json`), and `mcporter regenerate-cli <artifact>` to replay the stored invocation against the latest mcporter build.
Reach for `createRuntime()` when you need connection pooling, repeated calls, or advanced options such as explicit timeouts and log streaming. The runtime reuses transports, refreshes OAuth tokens, and only tears everything down when you call `runtime.close()`.
`pnpm mcporter:list` respects `MCPORTER_LIST_TIMEOUT` (milliseconds, default `30000`). The aggregated view fans out in parallel and prints either the tool count or a short status (e.g., `auth required — run 'mcporter auth <name>'`). Export a higher timeout when you need to inspect slow-starting servers:
## Compose Tools in Code
```
MCPORTER_LIST_TIMEOUT=120000 pnpm mcporter:list vercel
```
Common flags:
| Flag | Description |
| --- | --- |
| `--config <path>` | Path to `mcporter.json` (defaults to `./config/mcporter.json`). |
| `--root <path>` | Working directory for stdio commands (so `scripts/*` resolve correctly). |
| `--tail-log` | After the tool completes, print the last 20 lines of any referenced log file. |
| `--log-level <debug\|info\|warn\|error>` | Adjust CLI verbosity; defaults to `warn` (respecting `MCPORTER_LOG_LEVEL`). |
Prefer the flag for per-command tweaks, or set `MCPORTER_LOG_LEVEL=info` (or `debug`) to change the default globally.
### OAuth Flow
When a server entry declares `"auth": "oauth"`, the CLI/runtime will:
1. Launch a temporary callback server on `127.0.0.1`.
2. Open the authorization URL in your default browser (or print it if launching fails).
3. Exchange the resulting code and persist refreshed tokens under `~/.mcporter/<server>/`.
To reset credentials, delete that directory and rerun the command—`mcporter` will trigger a fresh login.
### Generate Standalone CLIs
`mcporter` can mint a fully standalone CLI for any server—handy when you want a single-purpose tool with friendly flags. You do **not** need an on-disk config; provide `--command` (optionally `--name`) or fall back to `--server '{...}'` for advanced options:
Generate a single executable you can ship to agents or drop on a PATH:
```bash
npx mcporter generate-cli --command https://mcp.context7.com/mcp --compile
chmod +x context7
./context7 list-tools
./context7 resolve-library-id react
```
Pass `--description` if you want a friendly summary in the generated help, or fall back to `--server '{...}'` when you need headers, env vars, or stdio commands.
If you omit `--name`, mcporter infers one from the command URL (for example, `https://mcp.context7.com/mcp` becomes `context7`).
The command writes `context7.ts` alongside a compiled `context7` binary. Generated CLIs embed the discovered schemas, so subsequent executions skip `listTools` round-trips and hit the network only for real tool calls. Use `--bundle` without a value to auto-name the output, and pass `--timeout` to raise the per-call default (30s). Add `--minify` to shrink bundled output. Compilation currently requires Bun; `--compile [path]` runs `bun build --compile` to emit a native executable, and when you omit the path the binary inherits the server name (`context7` in the example) so you can drop it straight onto your PATH.
> Tip: When Bun (or `BUN_BIN`) is available, `mcporter` defaults to `--runtime bun`; otherwise it falls back to Node. Pass `--runtime node` or `--runtime bun` to override explicitly.
## Composable Workflows
The package exports a thin runtime that lets you compose multiple MCP calls and post-process the results entirely in TypeScript. The example in `examples/context7-headlines.ts` demonstrates how to:
1. Resolve a library ID with `context7.resolve-library-id`
2. Fetch the docs via `context7.get-library-docs`
3. Derive a summary (markdown headings) locally
Use the pattern to build richer automations—batch fetch docs, search with Context7, or pass results into another MCP server without shelling out to the CLI.
Prefer the `createServerProxy` helper when you want an ergonomic proxy object for a server:
The runtime API is built for agents and scripts, not just humans at a terminal.
```ts
import { createRuntime, createServerProxy } from "mcporter";
const mcpRuntime = await createRuntime({
servers: [
{
name: "context7",
description: "Context7 docs MCP",
command: {
kind: "http",
url: new URL("https://mcp.context7.com/mcp"),
headers: process.env.CONTEXT7_API_KEY
? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` }
: undefined,
},
},
],
const runtime = await createRuntime();
const chrome = createServerProxy(runtime, "chrome-devtools");
const linear = createServerProxy(runtime, "linear");
const snapshot = await chrome.takeSnapshot();
console.log(snapshot.text());
const docs = await linear.searchDocumentation({
query: "automations",
page: 0,
});
// Inline definitions work at runtime; move this block to config/mcporter.json if you prefer static config.
const context7 = createServerProxy(mcpRuntime, "context7");
const search = await context7.resolveLibraryId("react");
const docs = await context7.getLibraryDocs("react"); // maps to required schema fields
console.log(search.text()); // "Available Libraries ..."
console.log(docs.markdown()); // markdown excerpt
await mcpRuntime.close();
console.log(docs.json());
```
Every property access maps from camelCase to the underlying tool name automatically (`resolveLibraryId` → `resolve-library-id`). Beyond method names, the proxy:
Friendly ergonomics baked into the proxy and result helpers:
- merges JSON-schema defaults so you only specify overrides;
- validates required arguments and throws helpful errors when fields are missing;
- returns a `CallResult` wrapper with `.raw`, `.text()`, `.markdown()`, `.json()`, and other helpers for quick post-processing.
- accepts primitives, tuples, or plain objects and routes them onto required schema fields in order (multi-argument tools like Firecrawls `scrape` work with positional calls);
- Property names map from camelCase to kebab-case tool names (`takeSnapshot` -> `take_snapshot`).
- Positional arguments map onto schema-required fields automatically, and option objects respect JSON-schema defaults.
- Results are wrapped in a `CallResult`, so you can choose `.text()`, `.markdown()`, `.json()`, `.content()`, or access `.raw` when you need the full envelope.
```ts
const firecrawl = createServerProxy(mcpRuntime, "firecrawl");
await firecrawl.firecrawlScrape(
"https://example.com/docs",
["markdown", "html"], // 2nd required/optional field from schema
{ waitFor: 5000 }, // merged as args
{ tailLog: true }, // treated as call options
);
Drop down to `runtime.callTool()` whenever you need explicit control over arguments, metadata, or streaming options.
## Generate a Standalone CLI
Turn any server definition into a shareable CLI artifact:
```bash
npx mcporter generate-cli \
--command https://mcp.context7.com/mcp
# Outputs:
# context7.ts (TypeScript template with embedded schemas)
# context7.js (bundled CLI via esbuild)
# context7.js.metadata.json
```
You can still drop down to `context7.call("resolve-library-id", { args: { ... } })` when you need explicit control.
- `--name` overrides the inferred CLI name.
- Add `--description "..."` if you want a custom summary in the generated help output.
- Add `--bundle [path]` to emit an esbuild bundle alongside the template.
- `--output <path>` writes the template somewhere specific.
- `--runtime bun|node` picks the runtime for generated code (Bun required for `--compile`).
- Add `--compile` to emit a Bun-compiled binary; mcporter cleans up intermediate bundles when you omit `--bundle`.
### Compose higher-level flows
Every artifact is paired with metadata capturing the generator version, resolved server definition, and invocation flags. Use:
Because the proxy already maps positional arguments to schema fields, you can layer custom helpers with plain JavaScript:
```ts
const context7 = createServerProxy(mcpRuntime, "context7");
async function getDocs(libraryName: string) {
const resolved = await context7.resolveLibraryId(libraryName);
const id =
resolved
.json<{ candidates?: Array<{ context7CompatibleLibraryID?: string }> }>()
?.candidates?.find((candidate) => candidate?.context7CompatibleLibraryID)
?.context7CompatibleLibraryID ??
resolved.text()?.match(/Context7-compatible library ID:\s*([^\s]+)/)?.[1];
if (!id) {
throw new Error(`Context7 library "${libraryName}" not found.`);
}
return context7.getLibraryDocs(id);
}
const docs = await getDocs("react");
console.log(docs.markdown());
```
npx mcporter inspect-cli dist/context7.js # human-readable summary
npx mcporter regenerate-cli dist/context7.js # replay with latest mcporter
```
The return value is still a `CallResult`, so you retain `.text()`, `.markdown()`, `.json()`, and friends.
## Configuration Reference
## Configuration
Define your servers in `config/mcporter.json` using the same shape Cursor and Claude Code expect:
`config/mcporter.json` mirrors Cursor/Claude's shape:
```jsonc
{
@ -249,36 +188,25 @@ Define your servers in `config/mcporter.json` using the same shape Cursor and Cl
}
```
Fields you can use:
What mcporter handles for you:
- `baseUrl` for HTTP/SSE servers.
- `command` + optional `args` for stdio servers.
- Optional metadata such as `description`, `headers`, `env`, `auth`, `tokenCacheDir`, and `clientName`.
- Convenience helpers `bearerToken` or `bearerTokenEnv` populate `Authorization` headers automatically.
- `${VAR}`, `${VAR:-fallback}`, and `$env:VAR` interpolation for headers and env entries.
- Automatic OAuth token caching under `~/.mcporter/<server>/` unless you override `tokenCacheDir`.
- Stdio commands inherit the directory of the file that defined them (imports or local config).
- Import precedence matches the array order; omit `imports` to use the default `["cursor", "claude-code", "claude-desktop", "codex"]`.
If you omit the optional `imports` array, `mcporter` automatically merges Cursor, Claude Code, Claude Desktop, and Codex configs (first entry wins on conflicts). Set `"imports": []` to disable or provide a custom order such as `"imports": ["cursor", "codex"]`.
Provide `configPath` or `rootDir` to CLI/runtime calls when you juggle multiple config files side by side.
Pass a different path via `createRuntime({ configPath })` when you need multiple configs side by side.
## Testing & CI
## Testing and CI
| Command | Purpose |
| --- | --- |
| `pnpm check` | Biome lint/format check. |
| `pnpm check` | Biome formatting plus Oxlint/tsgolint gate. |
| `pnpm build` | TypeScript compilation (emits `dist/`). |
| `pnpm test` | Vitest unit + integration suites (includes a streamable HTTP MCP fixture). |
| `pnpm test` | Vitest unit and integration suites (streamable HTTP fixtures included). |
GitHub Actions (`.github/workflows/ci.yml`) runs the same trio on every push and pull request.
## Roadmap
- Smoother OAuth UX (`mcporter auth <server>`, timeout warnings).
- Tailing for streaming `structuredContent`, not just file paths.
- Optional code generation for high-frequency tool schemas.
- Automated release tooling (changelog, tagged publishes).
For deeper architectural notes, see [`docs/spec.md`](docs/spec.md).
CI runs the same trio via GitHub Actions.
## License
MIT see [LICENSE](LICENSE).
MIT -- see [LICENSE](LICENSE).

View File

@ -57,3 +57,20 @@ child process, which mcporter will now terminate during shutdown.
messages, manually terminate the PID listed in the log.
- Always keep tmux sessions tidy after debugging: `tmux kill-session -t
<session>`.
- The CLI now forces `process.exit(0)` after cleanup by default so Node never
lingers on leaked handles. Export `MCPORTER_NO_FORCE_EXIT=1` if youre
debugging and need the process to stay alive.
- You can still set `MCPORTER_FORCE_EXIT=1` explicitly when you want to force
termination even with `MCPORTER_NO_FORCE_EXIT` in play.
## Upstream Tracking
- `@modelcontextprotocol/sdk` **1.21.0** is the latest release pulled into mcporter.
- Open SDK issues related to stdio shutdown:
- [#579 StdioClientTransport does not follow the spec on close](https://github.com/modelcontextprotocol/typescript-sdk/issues/579)
- [#780 onerror listeners not removed after client close (stdio)](https://github.com/modelcontextprotocol/typescript-sdk/issues/780)
- [#1049 stdio client crashes when spawned server exits unexpectedly](https://github.com/modelcontextprotocol/typescript-sdk/issues/1049)
We keep a local checkout of the SDK under `~/Projects/typescript-sdk/` so we can
diff against upstream and craft repros/patches quickly. Any mcporter-specific
workarounds live in `src/sdk-patches.ts` until the upstream fixes land.

54
docs/local.md Normal file
View File

@ -0,0 +1,54 @@
# Running mcporter Locally
You dont need `npx` every time—here are the three local entry points we use while developing mcporter itself.
## 1. Direct TypeScript entry (no build step)
All commands can be executed with `tsx` straight from `src/cli.ts`:
```bash
# list servers
pnpm exec tsx src/cli.ts list
# call a tool
pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react
# auth flow
pnpm exec tsx src/cli.ts auth vercel
```
These invocations match the `pnpm mcporter:*` scripts and are ideal when youre iterating on TypeScript without rebuilding.
## 2. Compiled CLI from `dist/`
When you want the same behaviour the published package ships with:
```bash
pnpm build # emits dist/...
node dist/cli.js list
node dist/cli.js call chrome-devtools.take_snapshot
```
Set flags exactly as you would in production:
```bash
MCPORTER_DEBUG_HANG=1 node dist/cli.js list
MCPORTER_NO_FORCE_EXIT=1 node dist/cli.js call linear.search_documentation query="automations"
```
## 3. Workspace executables
After `pnpm add mcporter` in your project (or inside this repo), the shim binaries are available:
```bash
pnpm mcporter:list
pnpm mcporter:call context7.get-library-docs topic=hooks
```
## Debug flags recap
- `MCPORTER_DEBUG_HANG=1` dumps active handles around shutdown (pairs well with tmux; see `docs/hang-debug.md`).
- `MCPORTER_NO_FORCE_EXIT=1` keeps the process alive even after cleanup (useful while inspecting debug output).
- `MCPORTER_FORCE_EXIT=1` force termination even if the above is set.
All three entry points honour the same `--config`, `--root`, and `--log-level` flags as the published CLI.

24
docs/tmux.md Normal file
View File

@ -0,0 +1,24 @@
# tmux Hang Diagnostics
Use `tmux` to verify whether a CLI command actually exits or is stalled on open handles. This keeps the main shell free while you inspect logs.
1. Start the command in a detached session:
```bash
tmux new-session -ds mcporter-check "pnpm exec tsx src/cli.ts list"
```
2. Wait a few seconds, then ask tmux if the session is still running:
```bash
tmux has-session -t mcporter-check
```
- Exit status **1** (`can't find session`) means the process exited normally.
- Exit status **0** means the command is still running (or hung) inside the session.
3. Capture the output without attaching:
```bash
tmux capture-pane -pt mcporter-check | tail -n 40
```
4. Once finished, clean up the session:
```bash
tmux kill-session -t mcporter-check
```
This workflow makes it easy to confirm whether `mcporter` commands return promptly after shutdown changes (for example, when debugging lingering MCP stdio servers). Use `MCPORTER_DEBUG_HANG=1` to emit active-handle diagnostics inside the tmux session when necessary.

View File

@ -152,8 +152,21 @@ async function main(): Promise<void> {
}
} finally {
terminateChildProcesses('runtime.finally');
// By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles
// (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1.
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
if (DEBUG_HANG) {
dumpActiveHandles('after terminateChildProcesses');
if (!disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1') {
process.exit(0);
}
} else {
const scheduleExit = () => {
if (!disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1') {
process.exit(0);
}
};
setImmediate(scheduleExit);
}
}
}

View File

@ -18,6 +18,7 @@ import {
type Logger,
type LogLevel,
} from './logging.js';
import './sdk-patches.js';
const PACKAGE_NAME = 'mcporter';
const CLIENT_VERSION = '0.2.0';
@ -378,7 +379,7 @@ async function closeTransportAndWait(
}
if (childProcess) {
await waitForChildClose(childProcess, 500).catch(() => {});
await waitForChildClose(childProcess, 1_000).catch(() => {});
}
if (!pidBeforeClose) {
@ -426,12 +427,14 @@ async function waitForChildClose(child: ChildProcess, timeoutMs: number): Promis
};
const cleanup = () => {
child.removeListener('close', finish);
child.removeListener('exit', finish);
child.removeListener('error', finish);
if (timer) {
clearTimeout(timer);
}
};
child.once('close', finish);
child.once('exit', finish);
child.once('error', finish);
let timer: NodeJS.Timeout | undefined;
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
@ -439,6 +442,55 @@ async function waitForChildClose(child: ChildProcess, timeoutMs: number): Promis
timer.unref?.();
}
});
try {
child.stdin?.end?.();
} catch {
// ignore
}
try {
child.stdout?.destroy?.();
child.stdout?.removeAllListeners?.();
(child.stdout as unknown as { unref?: () => void })?.unref?.();
} catch {
// ignore
}
try {
child.stderr?.destroy?.();
child.stderr?.removeAllListeners?.();
(child.stderr as unknown as { unref?: () => void })?.unref?.();
} catch {
// ignore
}
try {
const stdio = (child as { stdio?: unknown[] }).stdio;
if (Array.isArray(stdio)) {
for (const stream of stdio) {
if (!stream || typeof stream !== 'object') {
continue;
}
try {
(stream as { removeAllListeners?: () => void }).removeAllListeners?.();
(stream as { destroy?: () => void }).destroy?.();
(stream as { end?: () => void }).end?.();
} catch {
// ignore
}
}
}
} catch {
// ignore
}
try {
child.removeAllListeners();
} catch {
// ignore
}
try {
child.unref?.();
} catch {
// ignore
}
}
// isProcessAlive returns true when the target PID still exists.

170
src/sdk-patches.ts Normal file
View File

@ -0,0 +1,170 @@
import type { ChildProcess } from 'node:child_process';
import type { PassThrough } from 'node:stream';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
// Upstream TODO: Once typescript-sdk#579/#780/#1049 land, this shim can be dropped.
// We monkey-patch the transport so child processes actually exit and their stdio
// streams are destroyed; otherwise Node keeps the handles alive and mcporter hangs.
type MaybeChildProcess = ChildProcess & {
stdio?: Array<unknown>;
};
function destroyStream(stream: unknown): void {
if (!stream || typeof stream !== 'object') {
return;
}
try {
(stream as { removeAllListeners?: () => void }).removeAllListeners?.();
} catch {
// ignore
}
try {
(stream as { destroy?: () => void }).destroy?.();
} catch {
// ignore
}
try {
(stream as { end?: () => void }).end?.();
} catch {
// ignore
}
try {
(stream as { unref?: () => void }).unref?.();
} catch {
// ignore
}
}
function waitForChildClose(child: MaybeChildProcess | undefined, timeoutMs: number): Promise<void> {
if (!child) {
return Promise.resolve();
}
if ((child as { exitCode?: number | null }).exitCode !== null && (child as { exitCode?: number | null }).exitCode !== undefined) {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
};
const cleanup = () => {
child.removeListener('exit', finish);
child.removeListener('close', finish);
child.removeListener('error', finish);
if (timer) {
clearTimeout(timer);
}
};
child.once('exit', finish);
child.once('close', finish);
child.once('error', finish);
let timer: NodeJS.Timeout | undefined;
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timer = setTimeout(finish, timeoutMs);
timer.unref?.();
}
});
}
function patchStdioClose(): void {
const marker = Symbol.for('mcporter.stdio.patched');
const proto = StdioClientTransport.prototype as unknown as Record<symbol, unknown>;
if (proto[marker]) {
return;
}
StdioClientTransport.prototype.close = async function patchedClose(): Promise<void> {
const transport = this as unknown as {
_process?: MaybeChildProcess | null;
_stderrStream?: PassThrough | null;
_abortController?: AbortController | null;
_readBuffer?: { clear(): void } | null;
onclose?: () => void;
};
const child = transport._process ?? null;
const stderrStream = transport._stderrStream ?? null;
if (stderrStream) {
// Ensure any piped stderr stream is torn down so no file descriptors linger.
destroyStream(stderrStream);
transport._stderrStream = null;
}
// Abort active reads/writes and clear buffered state just like the SDK does.
transport._abortController?.abort();
transport._abortController = null;
transport._readBuffer?.clear?.();
transport._readBuffer = null;
if (!child) {
transport.onclose?.();
return;
}
// Closing stdin/stdout/stderr proactively lets Node release the handles even
// when the child ignores SIGTERM (common with npm/npx wrappers).
destroyStream(child.stdin);
destroyStream(child.stdout);
destroyStream(child.stderr);
const stdio = Array.isArray(child.stdio) ? child.stdio : [];
for (const stream of stdio) {
destroyStream(stream);
}
child.removeAllListeners?.();
let exited = await waitForChildClose(child, 700).then(
() => true,
() => false
);
if (!exited) {
// First escalation: polite SIGTERM.
try {
child.kill('SIGTERM');
} catch {
// ignore
}
exited = await waitForChildClose(child, 700).then(
() => true,
() => false
);
}
if (!exited) {
// Final escalation: SIGKILL. If this still fails, fall through and warn.
try {
child.kill('SIGKILL');
} catch {
// ignore
}
await waitForChildClose(child, 500).catch(() => {});
}
destroyStream(child.stdin);
destroyStream(child.stdout);
destroyStream(child.stderr);
const stdioAfter = Array.isArray(child.stdio) ? child.stdio : [];
for (const stream of stdioAfter) {
// Some transports mutate stdio in-place; run the destroy sweep again to be sure.
destroyStream(stream);
}
child.unref?.();
transport._process = null;
transport.onclose?.();
};
proto[marker] = true;
}
patchStdioClose();