Compare commits

..

No commits in common. "main" and "v0.11.3" have entirely different histories.

116 changed files with 1084 additions and 9935 deletions

View File

@ -1,711 +0,0 @@
---
name: crabbox
description: Use the Crabbox wrapper for OpenClaw remote validation across Linux, macOS, Windows, and WSL2, including delegated Blacksmith Testbox proof. Report the actual provider and id.
---
# Crabbox
Use the Crabbox wrapper when OpenClaw needs remote Linux proof for broad tests,
CI-parity checks, secrets, hosted services, Docker/E2E/package lanes, warmed
reusable boxes, sync timing, logs/results, cache inspection, or lease cleanup.
Crabbox is the transport/orchestration surface. The actual backend can be:
- brokered AWS Crabbox: direct provider, `provider=aws`, lease ids like
`cbx_...`, `syncDelegated=false`
- Blacksmith Testbox through Crabbox: delegated provider,
`provider=blacksmith-testbox`, ids like `tbx_...`, `syncDelegated=true`
For OpenClaw maintainer broad `pnpm` gates, Blacksmith Testbox through the
Crabbox wrapper is acceptable and often preferred when the standing Testbox
rules apply. Do not describe those runs as "AWS Crabbox"; report them as
Testbox-through-Crabbox with the `tbx_...` id and Actions run.
Use the repo `.crabbox.yaml` brokered AWS path when the task specifically needs
direct AWS Crabbox behavior, persistent direct-provider leases, `--fresh-pr`,
`--full-resync`, environment forwarding, capture/download support, or provider
comparison. Use `--provider blacksmith-testbox` when the task needs OpenClaw
maintainer Testbox proof, prepared CI environment, broad/heavy pnpm gates, or
the user asks for Testbox/Blacksmith.
## First Checks
- Run from the repo root. Crabbox sync mirrors the current checkout.
- Check the wrapper and providers before remote work:
```sh
command -v crabbox
../crabbox/bin/crabbox --version
pnpm crabbox:run -- --help | sed -n '1,120p'
../crabbox/bin/crabbox desktop launch --help
../crabbox/bin/crabbox webvnc --help
```
- OpenClaw scripts prefer `../crabbox/bin/crabbox` when present. The user PATH
shim can be stale.
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
means brokered AWS today.
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
If warmup drifts well past the minute-scale path, verify image promotion,
region/AZ placement, and FSR state before blaming OpenClaw.
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
Testbox policy applies.
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
`blacksmith testbox list`, use `blacksmith testbox list --all` before
concluding no box exists.
- If a warm direct-provider lease smells stale, retry with `--full-resync`
(alias `--fresh-sync`) before replacing the lease. This resets the remote
workdir, skips the fingerprint fast path, reseeds Git when possible, and
uploads the checkout from scratch.
- For live/provider bugs, use the configured secret workflow before downgrading
to mocks. Copy only the exact needed key into the remote process environment
for that one command. Do not print it, do not sync it as a repo file, and do
not leave it in remote shell history or logs. If no secret-safe injection path
is available, say true live provider auth is blocked instead of silently using
a fake key.
- Prefer local targeted tests for tight edit loops. Broad gates belong remote.
- Do not treat inherited shell env as operator intent. In particular,
`OPENCLAW_LOCAL_CHECK_MODE=throttled` from the local shell is not permission
to move broad `pnpm check:changed`, `pnpm test:changed`, full `pnpm test`, or
lint/typecheck fan-out onto the laptop.
- Only use `OPENCLAW_LOCAL_CHECK_MODE=throttled|full` when the user explicitly
asks for local proof in the current task. If Testbox is queued or capacity is
constrained, report the blocker and keep only targeted local edit-loop checks
running.
## macOS And Windows Targets
Use these only when the task needs an existing non-Linux host. OpenClaw broad
Linux validation uses the repo Crabbox config unless a provider is explicitly
requested.
Native brokered Windows is available for Windows-specific proof. Use the AWS
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
the bug is Windows-specific:
```sh
../crabbox/bin/crabbox warmup \
--provider aws \
--target windows \
--windows-mode normal \
--region us-west-2 \
--market on-demand \
--timing-json
```
The hydrate workflow assumes Docker should already be baked into Linux images
and only installs it as a fallback. Do not add per-run Docker installs to proof
commands unless the image probe shows Docker is actually missing.
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
macOS only after confirming the deployed coordinator supports EC2 Mac host
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
and IAM. Prefer `CRABBOX_HOST_ID` for a known Crabbox-managed Dedicated Host,
or run the no-spend preflight first:
```sh
crabbox admin hosts quota --provider aws --target macos --region eu-west-1 --type mac2.metal --json
crabbox admin hosts allocate --provider aws --target macos --region eu-west-1 --type mac2.metal --dry-run --json
CRABBOX_MACOS_TYPES=all scripts/macos-host-region-preflight.sh
```
Do not silently substitute AWS macOS for normal OpenClaw Linux proof. Report
paid-host blockers as quota, IAM, coordinator deployment, or host availability
instead of falling back to local macOS.
Crabbox supports static SSH targets:
```sh
../crabbox/bin/crabbox run --provider ssh --target macos --static-host mac-studio.local -- xcodebuild test
../crabbox/bin/crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local -- pwsh -NoProfile -Command "dotnet test"
../crabbox/bin/crabbox run --provider ssh --target windows --windows-mode wsl2 --static-host win-dev.local -- pnpm test
```
- `target=macos` and `target=windows --windows-mode wsl2` use the POSIX SSH,
bash, Git, rsync, and tar contract.
- Native Windows uses OpenSSH, PowerShell, Git, and tar; sync is manifest tar
archive transfer into `static.workRoot`. Direct native Windows runs support
`--script*`, `--env-from-profile`, `--preflight`, and PowerShell `--shell`.
- `crabbox actions hydrate/register` are Linux-only today; use plain
`crabbox run` loops for static macOS and Windows hosts.
- Live proof needs a reachable, operator-managed SSH host. Without one, verify
with `../crabbox/bin/crabbox run --help`, config/flag tests, and the Crabbox
Go test suite.
## Direct Brokered AWS Backend
Use this when the task needs direct AWS Crabbox semantics rather than the
prepared Blacksmith Testbox CI environment.
Changed gate:
```sh
pnpm crabbox:run -- \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
--shell -- \
"env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
```
Full suite:
```sh
pnpm crabbox:run -- \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
--shell -- \
"env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test"
```
Focused rerun:
```sh
pnpm crabbox:run -- \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
--shell -- \
"env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test <path-or-filter>"
```
Read the JSON summary. Useful fields:
- `provider`: `aws`
- `leaseId`: `cbx_...`
- `syncDelegated`: `false`
- `commandPhases`: populated when the command prints `CRABBOX_PHASE:<name>`
- `commandMs` / `totalMs`
- `exitCode`
Crabbox should stop one-shot AWS leases automatically after the run. Verify
cleanup when a run fails, is interrupted, or the command output is unclear:
```sh
../crabbox/bin/crabbox list --provider aws
```
## Blacksmith Testbox Through Crabbox
Use this for OpenClaw maintainer broad/heavy `pnpm` gates when the prepared CI
environment is the right proof surface:
```sh
node scripts/crabbox-wrapper.mjs run \
--provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
-- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 OPENCLAW_TESTBOX=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 pnpm check:changed
```
Read the JSON summary and the Testbox line. Useful fields:
- `provider`: `blacksmith-testbox`
- `leaseId`: `tbx_...`
- `syncDelegated`: `true`
- `syncPhases`: delegated/skipped because Blacksmith owns checkout/sync
- Actions run URL/id from the Testbox output
- `exitCode`
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
```sh
blacksmith testbox list --all
blacksmith testbox status <tbx_id>
```
## Observability Flags
Use these on debugging runs before inventing ad hoc logging:
- `--preflight`: prints run context, workspace mode, SSH target, remote user/cwd,
and target-specific tool probes. Defaults cover `git`, `tar`, `node`, `npm`,
`corepack`, `pnpm`, `yarn`, `bun`, `docker`, plus POSIX
`sudo`/`apt`/`bubblewrap` and native Windows
`powershell`/`execution_policy`/`longpaths`/`temp`/`pwsh`. Add
`--preflight-tools node,bun,docker`, `CRABBOX_PREFLIGHT_TOOLS`, or repo
`run.preflightTools` to replace the list. `default` expands built-ins; `none`
prints only the workspace summary. Preflight is diagnostic only; install
toolchains through Actions hydration, images, devcontainer/Nix/mise/asdf, or
the run script. On `blacksmith-testbox`, this prints a delegated-unsupported
note because the workflow owns setup.
- `CRABBOX_ENV_ALLOW=NAME,...`: forwards only listed local env vars for direct
providers and prints `set len=N secret=true` style summaries. On
`blacksmith-testbox`, env forwarding is unsupported; put secrets in the
Testbox workflow instead.
- `--env-from-profile <file>` plus `--allow-env NAME`: loads simple
`export NAME=value` / `NAME=value` lines from a local profile without
executing it, then forwards only allowlisted names. `--allow-env` is
repeatable and comma-separated. Profile values override ambient allowlisted
env values for that run. Direct POSIX, WSL2, and native Windows runs are
supported; delegated providers are not. Crabbox probes the uploaded profile
remotely and prints redacted presence/length metadata before the command.
- `--env-helper <name>`: with `--env-from-profile` on POSIX SSH targets,
persists `.crabbox/env/<name>` and `.crabbox/env/<name>.env` so follow-up
commands on the same lease can run through `./.crabbox/env/<name> <command>`.
Use only on leases you control; the profile stays until cleanup, lease reset,
or `--full-resync`.
- `--script <file>` / `--script-stdin`: upload a local script into
`.crabbox/scripts/` and execute it on the remote box. Shebang scripts execute
directly on POSIX; scripts without a shebang run through `bash`. Native
Windows uploads run through Windows PowerShell, and Crabbox appends `.ps1`
when needed. Arguments after `--` become script args.
- `--fresh-pr owner/repo#123|URL|number`: skip dirty local sync and create a
fresh remote checkout of the GitHub PR. Bare numbers use the current repo's
GitHub origin. Add `--apply-local-patch` only when the current local
`git diff --binary HEAD` should be applied on top of that PR checkout.
- `--full-resync` / `--fresh-sync`: reset a stale direct-provider workdir
before syncing. Use after sync fingerprints look wrong, SSH times out before
sync, or rsync watchdog output suggests it. It is redundant with
`--fresh-pr`, incompatible with `--no-sync`, and unsupported by delegated
providers.
- `--capture-stdout <path>` / `--capture-stderr <path>`: write remote streams to
local files and keep binary/noisy output out of retained logs. Parent
directories must already exist. These are direct-provider only.
- `--capture-on-fail`: on non-zero direct-provider exits, downloads
`.crabbox/captures/*.tar.gz` with `test-results`, `playwright-report`,
`coverage`, JUnit XML, and nearby logs. Treat as secret-bearing until reviewed.
- `--keep-on-failure`: leave a failed one-shot lease alive for live debugging
until idle/TTL expiry. Useful on direct providers and delegated one-shots.
- `--timing-json`: final machine-readable timing. Add
`echo CRABBOX_PHASE:install`, `CRABBOX_PHASE:test`, etc. in long shell
commands; direct providers and Blacksmith Testbox both report them as
`commandPhases`.
Live-provider debug template for direct AWS/Hetzner leases:
```sh
mkdir -p .crabbox/logs
pnpm crabbox:run -- --provider aws \
--preflight \
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
--timing-json \
--capture-stdout .crabbox/logs/live-provider.stdout.log \
--capture-stderr .crabbox/logs/live-provider.stderr.log \
--capture-on-fail \
--shell -- \
"echo CRABBOX_PHASE:install; pnpm install --frozen-lockfile; echo CRABBOX_PHASE:test; pnpm test:live"
```
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
`--sync-only` to delegated providers. Also do not pass `--script*`,
`--fresh-pr`, `--full-resync`, or `--env-helper` there. Crabbox rejects these
because the provider owns sync or command transport. `--keep-on-failure` is OK
for delegated one-shots when you need to inspect a failed lease.
## Efficient Bug E2E Verification
Use the smallest Crabbox lane that proves the reported user path, not just the
touched code. Aim for one after-fix E2E proof before commenting, closing, or
opening a PR for a user-visible bug.
When the user says "test in Crabbox", do not simply copy tests to the remote
box and run them there. Crabbox is for remote real-scenario proof: copy or
install OpenClaw as the user would, run the same setup/update/CLI/Gateway/API
call that failed, and capture behavior from that entrypoint. For regressions or
bug reports, prove the broken state first when feasible, then run the same
scenario after the fix.
Pick the lane by symptom:
- Docker/setup/install bug: build a package tarball and run the matching
`scripts/e2e/*-docker.sh` or package script. This proves npm packaging,
install paths, runtime deps, config writes, and container behavior.
- Provider/model/auth bug: prefer true live E2E. Use the configured secret
workflow, then inject the single needed key into Crabbox if needed. Scrub
unrelated provider env vars in the child command so interactive defaults do
not drift to another provider. If only a dummy key is used, label the proof
narrowly, e.g. "UI/install path only; live provider auth not exercised."
- Channel delivery bug: use the channel Docker/live lane when available; include
setup, config, gateway start, send/receive or agent-turn proof, and redacted
logs.
- Gateway/session/tool bug: prefer an end-to-end CLI or Gateway RPC command that
creates real state and inspects the resulting files/API output.
- Pure parser/config bug: targeted tests may be enough, but still run a
Crabbox command when OS, package, Docker, secrets, or service lifecycle could
change behavior.
Efficient flow:
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
when feasible. If the issue cannot be reproduced, capture the exact command
and observed behavior instead.
2. Patch locally and run narrow local tests for edit speed.
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
package install, Docker setup, onboarding, channel add, gateway start, or
agent turn as appropriate.
4. Record proof as: Testbox id, command, environment shape, redacted secret
source, and copied success/failure output.
5. If the issue says "cannot reproduce", ask for the missing config/log fields
that would distinguish the tested path from the reporter's path.
Keep it efficient:
- Reuse existing E2E scripts and helper assertions before writing ad hoc shell.
- Use `--script <file>` or `--script-stdin` for multi-line E2E commands instead
of quote-heavy `--shell` strings on direct SSH providers.
- Use `--fresh-pr <pr>` when validating an upstream PR in isolation from the
local dirty tree. Add `--apply-local-patch` only when testing a local fixup on
top of that PR.
- Use `--full-resync` before replacing a warmed direct-provider lease when the
remote workdir or sync fingerprint appears stale.
- Use one-shot Crabbox for a single proof; use a reusable Testbox only when
several commands must share built images, installed packages, or live state.
- Prefer `OPENCLAW_CURRENT_PACKAGE_TGZ` with Docker/package lanes when testing a
candidate tarball; prefer the repo's package helper instead of direct source
execution when the bug might be packaging/install related.
- Keep secrets redacted. It is fine to report key presence, source, and length;
never print secret values.
- Include `--timing-json` on broad or flaky runs when command duration or sync
behavior matters.
Before/after PR proof on delegated Testbox:
- For PRs that should prove "broken before, fixed after", compare base and PR
on the same Testbox when practical. Fetch both refs, create detached temp
worktrees under `/tmp`, install in each, then run the same harness twice.
- Do not checkout base/PR refs in the synced repo root. Delegated Testbox sync
may leave the root dirty with local files; `git checkout` can abort or mix
proof state.
- Temp harness files under `/tmp` do not resolve repo packages by default. Put
the harness inside the worktree, or in ESM use
`createRequire(path.join(process.cwd(), "package.json"))` before requiring
workspace deps such as `@lydell/node-pty`.
- For full-screen TUI/CLI bugs, a PTY harness is stronger than helper-only
assertions. Use a real PTY, wait for visible lifecycle markers, send input,
then send control keys and assert process exit/stuck behavior.
- When validating a rebased local branch before push, remember delegated sync
usually validates synced file content on a detached dirty checkout, not a
remote commit object. Record the local head SHA, changed files, Testbox id,
and final success markers; after pushing, ensure the pushed SHA has the same
file content.
- If GitHub CI is still queued but the exact changed content passed Testbox
`pnpm check:changed`, `pnpm check:test-types`, and the real E2E proof, it is
reasonable to merge once required checks allow it. Note any still-running
unrelated shards in the proof comment instead of waiting forever.
Interactive CLI/onboarding:
- For full-screen or prompt-heavy CLI flows, run the target command inside tmux
on the Crabbox and drive it with `tmux send-keys`; capture proof with
`tmux capture-pane`, redacted through `sed`.
- Prefer deterministic arrow navigation over search typing for Clack-style
searchable selects. Raw `send-keys -l openai` may not trigger filtering in a
tmux pane; inspect option order locally or on-box and send exact Down/Enter
sequences.
- Isolate mutable state with `OPENCLAW_STATE_DIR=$(mktemp -d)`. Plugin npm
installs live under that state dir (`npm/node_modules/...`), not under
`OPENCLAW_CONFIG_DIR`. Verify downloads by checking the state dir, package
lock, and installed package metadata.
- To test automatic setup installs against local package artifacts, use
`OPENCLAW_ALLOW_PLUGIN_INSTALL_OVERRIDES=1` plus
`OPENCLAW_PLUGIN_INSTALL_OVERRIDES='{"plugin-id":"npm-pack:/tmp/plugin.tgz"}'`.
Pack with `npm pack`, set an isolated `OPENCLAW_STATE_DIR`, and verify the
package under `npm/node_modules`. Overrides are test-only and must not be
treated as official/trusted-source installs.
- For OpenAI/Codex onboarding proof, the useful markers are the UI line
`Installed Codex plugin`, `npm/node_modules/@openclaw/codex`, and the
package-lock entry showing the bundled `@openai/codex` dependency. A dummy
OpenAI-shaped key can prove only UI/install behavior; it is not live auth.
## Reuse And Keepalive
For most Crabbox calls, one-shot is enough. Use reuse only when you need
multiple manual commands on the same hydrated box.
If Crabbox returns a reusable id or you intentionally keep a lease:
```sh
pnpm crabbox:run -- --id <cbx_id-or-slug> --no-sync --timing-json --shell -- "pnpm test <path>"
```
Stop boxes you created before handoff:
```sh
pnpm crabbox:stop -- <id-or-slug>
blacksmith testbox stop --id <tbx_id>
```
## Interactive Desktop And WebVNC
Prefer WebVNC for human inspection because the browser portal can preload the
lease VNC password and avoids a native VNC client's copy/paste/password dance.
Use native `crabbox vnc` only when WebVNC is unavailable, the browser portal is
broken, or the user explicitly wants a local VNC client.
Common desktop flow:
```sh
../crabbox/bin/crabbox warmup --provider hetzner --desktop --browser --class standard --idle-timeout 60m --ttl 240m
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open --take-control
```
Useful WebVNC commands:
```sh
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox webvnc daemon status --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc daemon stop --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc status --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox desktop doctor --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox desktop click --provider hetzner --id <cbx_id-or-slug> --x 640 --y 420
../crabbox/bin/crabbox desktop paste --provider hetzner --id <cbx_id-or-slug> --text "user@example.com"
../crabbox/bin/crabbox desktop key --provider hetzner --id <cbx_id-or-slug> ctrl+l
../crabbox/bin/crabbox artifacts collect --id <cbx_id-or-slug> --all --output artifacts/<slug>
../crabbox/bin/crabbox artifacts publish --dir artifacts/<slug> --pr <number>
```
`desktop launch --webvnc --open` is usually the nicest one-shot: it starts the
browser/app inside the visible session, bridges the lease into the authenticated
WebVNC portal, and opens the portal. Keep browsers windowed for human QA; use
`--fullscreen` only for capture/video workflows.
For human handoff, include `--take-control` so the opened portal viewer gets
keyboard/mouse control automatically instead of landing as an observer.
Human handoff preflight:
- Do not assume a visible desktop or launched browser means the repo CLI/app is
installed, built, or on the interactive terminal's `PATH`.
- Before handing WebVNC to a human tester, prove the expected command from the
same kept lease and from a neutral directory such as `~`.
- If the handoff needs repo-local code, sync/build/link it explicitly on that
lease. Source-tree CLIs often need build output before a symlink works.
- Prefer a real `command -v <expected-command> && <expected-command> --version`
check over a repo-root-only `pnpm ...` command.
Generic handoff repair pattern:
```sh
../crabbox/bin/crabbox run --id <cbx_id-or-slug> --full-resync --shell -- \
"set -euo pipefail
pnpm install --frozen-lockfile
pnpm build
sudo ln -sf \"\$PWD/<cli-entry>\" /usr/local/bin/<expected-command>
cd ~
command -v <expected-command>
<expected-command> --version"
```
## If Crabbox Fails
Keep the fallback narrow. First decide whether the failure is Crabbox itself,
the brokered AWS lease, Blacksmith/Testbox, repo hydration, sync, or the test
command.
Fast checks:
```sh
command -v crabbox
../crabbox/bin/crabbox --version
pnpm crabbox:run -- --help | sed -n '1,140p'
../crabbox/bin/crabbox doctor
command -v blacksmith
blacksmith --version
blacksmith testbox list
```
Common Crabbox-only failures:
- Provider missing or old CLI: use `../crabbox/bin/crabbox` from the sibling
repo, or update/install Crabbox before retrying.
- Bad local config: inspect `.crabbox.yaml`, `crabbox config show`, and
`crabbox whoami`; normal OpenClaw proof should use brokered AWS without
asking for cloud keys.
- Slug/claim confusion: use the raw `cbx_...` / `tbx_...` id, or run one-shot
without `--id`.
- Sync/timing bug: add `--debug --timing-json`; capture the final JSON and the
printed Actions URL. Large sync warnings now include top source directories
by file count and a hint to update `.crabboxignore` / `sync.exclude`; inspect
those before reaching for `--force-sync-large`. Quiet rsync watchdogs and SSH
timeouts now print `next_action=` hints; follow them, usually `--full-resync`
first and a fresh lease second.
- Cleanup uncertainty: run `crabbox list --provider aws`; for explicit
Blacksmith runs, use `blacksmith testbox list` and stop only boxes you
created.
- Testbox queued/capacity pressure: do not retry Blacksmith repeatedly. Rerun
once without `--provider` so `.crabbox.yaml` routes to brokered AWS, or report
the Blacksmith blocker if Testbox itself is the requested proof.
If brokered AWS cannot dispatch, sync, attach, or stop, retry once with
`--debug` and `--timing-json`:
```sh
pnpm crabbox:run -- --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed
```
Full suite:
```sh
pnpm crabbox:run -- --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test
```
Auth fallback, only when `blacksmith` says auth is missing:
```sh
blacksmith auth login --non-interactive --organization openclaw
```
Raw Blacksmith footguns:
- Run from repo root. The CLI syncs the current directory.
- Save the returned `tbx_...` id in the session.
- Reuse that id for focused reruns; stop it before handoff.
- Raw commit SHAs are not reliable `warmup --ref` refs; use a branch or tag.
- Treat `blacksmith testbox list` as cleanup diagnostics, not a shared reusable
queue.
Use Blacksmith only when the task is specifically about Testbox, brokered AWS
is unavailable, or an explicit comparison is needed. If Blacksmith is down or
quota-limited, do not keep probing it; stay on brokered AWS and note the
delegated-provider outage.
## Blacksmith Backend Notes
Crabbox Blacksmith backend delegates setup to:
- org: `openclaw`
- workflow: `.github/workflows/ci-check-testbox.yml`
- job: `check`
- ref: `main` unless testing a branch/tag intentionally
The hydration workflow owns checkout, Node/pnpm setup, dependency install,
secrets, ready marker, and keepalive. Crabbox owns dispatch, sync, SSH command
execution, timing, logs/results, and cleanup.
Minimal Blacksmith-backed Crabbox run, from repo root:
```sh
pnpm crabbox:run -- --provider blacksmith-testbox --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test:changed
```
Use direct Blacksmith only when Crabbox is the broken layer and you are
isolating a Crabbox bug. Prefer direct `blacksmith testbox list` for cleanup
diagnostics, not as a reusable work queue.
Important Blacksmith footguns:
- Always run from repo root. The CLI syncs the current directory.
- Raw commit SHAs are not reliable `warmup --ref` refs; use a branch or tag.
- If auth is missing and browser auth is acceptable:
```sh
blacksmith auth login --non-interactive --organization openclaw
```
## Brokered AWS
Use AWS for normal OpenClaw remote proof. The repo `.crabbox.yaml` already
selects brokered AWS, so omit `--provider` unless you are testing a different
provider deliberately.
```sh
pnpm crabbox:warmup -- --class beast --market on-demand --idle-timeout 90m
pnpm crabbox:hydrate -- --id <cbx_id-or-slug>
pnpm crabbox:run -- --id <cbx_id-or-slug> --timing-json --shell -- "env NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
pnpm crabbox:stop -- <cbx_id-or-slug>
```
Install/auth for owned Crabbox if needed:
```sh
brew install openclaw/tap/crabbox
crabbox login --url https://crabbox.openclaw.ai --provider aws
```
New users should self-resolve broker auth before anyone asks for AWS keys:
```sh
crabbox config show
crabbox doctor
crabbox whoami
```
- If broker auth is missing, run `crabbox login --url https://crabbox.openclaw.ai --provider aws`.
- If the CLI asks for `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, or AWS
profile setup during normal OpenClaw validation, assume the agent selected
the wrong path. Use brokered `crabbox login` or an existing brokered lease
before asking the user for cloud credentials.
- Ask for AWS keys only for explicit direct-provider/account administration,
not for normal brokered OpenClaw proof.
- Trusted automation may still use
`printf '%s' "$CRABBOX_COORDINATOR_TOKEN" | crabbox login --url https://crabbox.openclaw.ai --provider aws --token-stdin`.
macOS config lives at:
```text
~/Library/Application Support/crabbox/config.yaml
```
It should include `broker.url`, `broker.token`, and usually `provider: aws`
for OpenClaw lanes. Let that config drive normal validation.
### Interactive Desktop / WebVNC
For human desktop demos, prefer `webvnc` over native `vnc` and keep the remote
desktop visible/windowed. Do not fullscreen the remote browser or hide the XFCE
panel/window chrome unless the explicit goal is video/capture output. After
launch, verify a screenshot shows the desktop panel plus browser title bar. If
Chrome is fullscreen, toggle it back with:
```sh
crabbox run --id <lease> --shell -- 'DISPLAY=:99 xdotool search --onlyvisible --class google-chrome windowactivate key F11'
```
## Diagnostics
```sh
crabbox status --id <id-or-slug> --wait
crabbox inspect --id <id-or-slug> --json
crabbox sync-plan
crabbox history --limit 20
crabbox history --lease <id-or-slug>
crabbox attach <run_id>
crabbox events <run_id> --json
crabbox logs <run_id>
crabbox results <run_id>
crabbox cache stats --id <id-or-slug>
crabbox ssh --id <id-or-slug>
blacksmith testbox list
```
Use `--debug` on `run` when measuring sync timing.
Use `--timing-json` on warmup, hydrate, and run when comparing backends.
Use `--market spot|on-demand` only on AWS warmup/one-shot runs.
## Failure Triage
- Crabbox cannot find provider: verify `../crabbox/bin/crabbox --help` lists
the provider selected by `.crabbox.yaml`; update Crabbox before falling back.
- Hydration stuck or failed: open the printed GitHub Actions run URL and inspect
the hydration step.
- Sync failed: rerun with `--debug`; check changed-file count and whether the
checkout is dirty.
- Command failed: rerun only the failing shard/file first. Do not rerun a full
suite until the focused failure is understood.
- Cleanup uncertain: `crabbox list --provider aws`; for explicit Blacksmith
runs, use `blacksmith testbox list` and stop owned `tbx_...` leases you
created.
- Crabbox broken but Blacksmith works: use the direct Blacksmith fallback above,
then file/fix the Crabbox issue.
## Boundary
Do not add OpenClaw-specific setup to Crabbox itself. Put repo setup in the
hydration workflow and keep Crabbox generic around lease, sync, command
execution, logs/results, timing, and cleanup.

View File

@ -1,53 +0,0 @@
profile: mcporter-check
provider: aws
class: standard
capacity:
market: spot
strategy: most-available
fallback: on-demand-after-120s
hints: true
regions:
- eu-west-1
- eu-west-2
- eu-central-1
- us-east-1
- us-west-2
actions:
workflow: .github/workflows/crabbox-hydrate.yml
job: hydrate
ref: main
runnerLabels:
- crabbox
- openclaw
- mcporter
runnerVersion: latest
ephemeral: true
aws:
region: eu-west-1
rootGB: 160
sync:
delete: true
checksum: false
gitSeed: true
fingerprint: true
baseRef: main
exclude:
- .artifacts
- .codex
- .DS_Store
- coverage
- dist
- node_modules
- bin
env:
allow:
- CI
- CGO_*
- GOFLAGS
- GOWORK
- NODE_OPTIONS
- PNPM_*
- NPM_CONFIG_*
ssh:
user: crabbox
port: '2222'

7
.github/CODEOWNERS vendored
View File

@ -1,7 +0,0 @@
# Protect ownership and automation rules.
/.github/CODEOWNERS @openclaw/openclaw-secops
/.github/workflows/ @openclaw/openclaw-secops
/package.json @openclaw/openclaw-secops
/.github/actionlint.yaml @openclaw/openclaw-secops
/.agents/skills/ @openclaw/openclaw-secops
/.crabbox.yaml @openclaw/openclaw-secops

View File

@ -1,5 +0,0 @@
self-hosted-runner:
labels:
- crabbox
- openclaw
- mcporter

View File

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

View File

@ -1,126 +0,0 @@
name: Crabbox Hydrate
on:
workflow_dispatch:
inputs:
crabbox_id:
description: 'Crabbox lease ID'
required: true
type: string
ref:
description: 'Git ref to hydrate'
required: false
type: string
crabbox_runner_label:
description: 'Dynamic Crabbox runner label'
required: true
type: string
crabbox_job:
description: 'Hydration job identifier expected by Crabbox'
required: false
default: 'hydrate'
type: string
crabbox_keep_alive_minutes:
description: 'Minutes to keep the hydrated job alive'
required: false
default: '90'
type: string
permissions:
contents: read
env:
NODE_VERSION: '24'
PNPM_VERSION: '10.33.2'
jobs:
hydrate:
name: hydrate
runs-on: [self-hosted, crabbox, openclaw, mcporter, '${{ inputs.crabbox_runner_label }}']
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
- uses: pnpm/action-setup@v6.0.8
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Prepare pnpm workspace
shell: bash
run: |
set -euo pipefail
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
pnpm install --frozen-lockfile
node --version
pnpm --version
pnpm --version
- name: Mark Crabbox ready
shell: bash
env:
CRABBOX_ID: ${{ inputs.crabbox_id }}
CRABBOX_JOB: ${{ inputs.crabbox_job }}
run: |
set -euo pipefail
job="${CRABBOX_JOB}"
if [ -z "$job" ]; then job=hydrate; fi
case "$CRABBOX_ID" in
''|*[!A-Za-z0-9._-]*)
echo "Invalid crabbox_id" >&2
exit 2
;;
esac
mkdir -p "$HOME/.crabbox/actions"
state="$HOME/.crabbox/actions/${CRABBOX_ID}.env"
env_file="$HOME/.crabbox/actions/${CRABBOX_ID}.env.sh"
{
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE PATH; do
value="${!key-}"
if [ -n "$value" ]; then
printf 'export %s=%q\n' "$key" "$value"
fi
done
} > "${env_file}.tmp"
mv "${env_file}.tmp" "$env_file"
tmp="${state}.tmp"
{
echo "WORKSPACE=${GITHUB_WORKSPACE}"
echo "RUN_ID=${GITHUB_RUN_ID}"
echo "JOB=${job}"
echo "ENV_FILE=${env_file}"
echo "READY_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$tmp"
mv "$tmp" "$state"
- name: Keep Crabbox job alive
shell: bash
env:
CRABBOX_ID: ${{ inputs.crabbox_id }}
CRABBOX_KEEP_ALIVE_MINUTES: ${{ inputs.crabbox_keep_alive_minutes }}
run: |
set -euo pipefail
case "$CRABBOX_ID" in
''|*[!A-Za-z0-9._-]*)
echo "Invalid crabbox_id" >&2
exit 2
;;
esac
minutes="${CRABBOX_KEEP_ALIVE_MINUTES}"
case "$minutes" in
''|*[!0-9]*) minutes=90 ;;
esac
stop="$HOME/.crabbox/actions/${CRABBOX_ID}.stop"
deadline=$(( $(date +%s) + minutes * 60 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if [ -f "$stop" ]; then
exit 0
fi
sleep 15
done

View File

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

View File

@ -1,57 +1,5 @@
# 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
- Add cache-friendly `disableOAuth` support across headless runtime, CLI, daemon, proxy, and `callOnce` paths so callers can suppress interactive OAuth without losing connection reuse. (Issues #197, #199, #201, thanks @feniix)
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
- Prevent concurrent OAuth vault updates from briefly exposing empty lock files and losing credential entries under load.
### CLI
- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/<server>`, exposing one keep-alive server with original tool names while preserving aggregate `/mcp` namespacing. (PR #194, thanks @zm2231)
- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123)
- Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231)
- Return a non-zero exit code for explicit `mcporter list <unknown-server>` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674)
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
- Keep CloudBase MCP alive by default so device-code authentication can finish polling and persist credentials after returning `AUTH_PENDING`. (PR #193, thanks @sevzq)
- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr)
### Tooling / Dependencies
- Refresh development dependencies and satisfy the stricter `oxlint` check.
## [0.11.3] - 2026-05-21
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)

View File

@ -21,7 +21,6 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr
- **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.
- **Typed tool clients.** `mcporter emit-ts` emits `.d.ts` interfaces or ready-to-run client wrappers so agents/tests can call MCP servers with strong TypeScript types without hand-writing plumbing.
- **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()`, `.images()`, and `.content()` helpers.
- **Record/replay fixtures.** `mcporter record` captures MCP JSON-RPC traffic as NDJSON, and `mcporter replay` serves the same responses deterministically for offline debugging and redacted repros.
- **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.
- **Ad-hoc connections.** Point the CLI at _any_ MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. Hosted MCPs that expect a browser login (Supabase, Vercel, etc.) are auto-detected—just run `mcporter auth <url>` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md).
@ -143,7 +142,6 @@ 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
@ -164,7 +162,6 @@ 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.
@ -201,7 +198,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
- Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env.
- All other servers stay ephemeral; add `"lifecycle": "keep-alive"` to a server entry (or set `MCPORTER_KEEPALIVE=name`) when you want the daemon to manage it. You can also set `"lifecycle": "ephemeral"` (or `MCPORTER_DISABLE_KEEPALIVE=name`) to opt out.
- The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon.
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/<server>` for one selected keep-alive server with its original, unprefixed tool names.
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`.
- Troubleshooting? Run `mcporter daemon start --log` (or `--log-file /tmp/daemon.log`) to tee stdout/stderr into a file, and add `--log-servers chrome-devtools` when you only want call traces for a specific MCP. Per-server configs can also set `"logging": { "daemon": { "enabled": true } }` to force detailed logging for that entry.
## Friendlier Tool Calls
@ -256,7 +253,7 @@ const result = await callOnce({
console.log(result); // raw MCP envelope
```
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex/Windsurf/OpenCode/VS Code 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. In headless contexts, pass `disableOAuth: true` to suppress interactive OAuth and rely on cached tokens only — the library equivalent of the CLI's `--no-oauth` flag.
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex/Windsurf/OpenCode/VS Code 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.
## Compose Automations with the Runtime

View File

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

View File

@ -38,8 +38,6 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--exit-code` exit 1 when any checked server is unhealthy.
- `--quiet` suppress output and exit 1 when any checked server is unhealthy.
- `--timeout <ms>` per-server timeout when enumerating all servers.
- `--no-oauth` never start an interactive OAuth flow; use cached
tokens only while keeping eligible connections pooled.
## `mcporter call <server.tool>`
@ -53,10 +51,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.
## `mcporter resource <server> [uri]`
@ -68,8 +63,6 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--output auto|text|markdown|json|raw` choose how to render the response.
- `--json` shortcut for `--output json`.
- `--raw` shortcut for `--output raw`.
- `--no-oauth` never start an interactive OAuth flow; use cached
tokens only while keeping eligible connections pooled.
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
@ -78,17 +71,14 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `tools/list` queries the daemon for each selected server and publishes tools
as `server__tool`; `tools/call` strips the prefix and routes the call through
the daemon.
- In HTTP mode, `/mcp` keeps the aggregate namespaced bridge, while
`/mcp/<server>` exposes one selected keep-alive server with its original,
unprefixed tool names.
- Only configured keep-alive servers participate. Add
`"lifecycle": "keep-alive"` to a server definition when you want it managed
by the daemon.
- Flags:
- `--stdio` serve MCP over stdio; this is the default and is the mode to
register with Claude Code, Codex, and similar clients.
- `--http <port>` serve MCP Streamable HTTP on `/mcp` and
`/mcp/<server>`, bound to `127.0.0.1` by default.
- `--http <port>` serve MCP Streamable HTTP on `/mcp`, bound to
`127.0.0.1` by default.
- `--host <host>` override the HTTP bind host when you intentionally need a
non-local listener.
- `--servers <csv>` expose only the listed keep-alive server names.

View File

@ -8,7 +8,7 @@ read_when:
## Goals
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools` or CloudBase device authentication). No extra flags for agents.
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools`). No extra flags for agents.
- **Shared state:** Multiple CLI invocations/agents within the same user session must reuse the same warm transport so STDIO servers can hold tabs, cookies, and other stateful context.
- **Per-config scope:** The daemon lives under the current user account, keyed by config path (`~/.mcporter/daemon/daemon-<config-hash>.sock` on Unix-like systems, or `$XDG_STATE_HOME/mcporter/daemon/...` when set), and never crosses user boundaries.
- **Resilience:** If the daemon or a keep-alive server crashes, the next CLI call restarts it automatically.
@ -34,7 +34,7 @@ read_when:
- **Keep-alive detection:**
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
- Provide a config-level `defaultKeepAlive` array or `MCPORTER_KEEPALIVE` env var for quick overrides.
- Ship a hardcoded allowlist (`chrome-devtools`, `mobile-mcp`, `playwright`, `cloudbase`) so existing configs benefit immediately; users can opt out per server.
- Ship a hardcoded allowlist (initially `chrome-devtools`, `mobile-mcp`, `playwright`) so existing configs benefit immediately; users can opt out per server.
## CLI Surface

View File

@ -42,7 +42,7 @@ mcporter leans into the **code-execution-with-MCP** pattern Anthropic recommends
- **Typed clients.** [`mcporter emit-ts`](emit-ts.md) emits `.d.ts` interfaces or a ready-to-run client wrapping `createServerProxy()` so agents call MCP tools with full TypeScript types.
- **Friendly composable API.** [`createServerProxy()`](tool-calling.md) maps tools to camelCase methods, applies JSON-schema defaults, validates required arguments, and returns a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()` helpers.
- **Ad-hoc connections + auto-OAuth.** Point the CLI at any MCP endpoint (HTTP, SSE, stdio) without touching config. Hosted MCPs that need a browser login (Supabase, Vercel, etc.) are auto-detected — `mcporter auth <url>` promotes the definition to OAuth on the fly. See [Ad-hoc connections](adhoc.md).
- **MCP bridge for agents.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP server with namespaced `server__tool` tools, or as per-server HTTP paths that keep original tool names.
- **MCP bridge for agents.** `mcporter serve --stdio` exposes daemon-managed keep-alive servers as one MCP server, with tools namespaced as `server__tool`, so clients can share the same warm daemon-backed transports.
- **OAuth & stdio ergonomics.** Built-in OAuth caching, token refresh, log tailing, and stdio wrappers — same interface across HTTP, SSE, and stdio transports.
## Built for agents

View File

@ -35,7 +35,7 @@ Use `createServerProxy(runtime, name)` inside scripts when you want ergonomic ca
2. Automatically merges default values.
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control. Headless callers that must rely on cached tokens without launching OAuth can pass `disableOAuth: true` to `connect`, `callTool`, `listTools`, resource helpers, and `callOnce`; this suppresses interactive OAuth while keeping eligible connections pooled.
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control.
## Debug + Support Docs

View File

@ -1,52 +0,0 @@
---
summary: 'How to record MCP JSON-RPC traffic to NDJSON and replay it deterministically for offline debugging.'
read_when:
- 'Debugging or reproducing MCP-backed tool calls without contacting the live server.'
---
# Record and replay MCP calls
`mcporter record` captures the JSON-RPC traffic between the runtime and configured MCP servers. `mcporter replay` reads the captured stream and serves the recorded responses back to the same requests without contacting the live MCP server.
Recordings live under `~/.mcporter/recordings/` as newline-delimited JSON:
```bash
mcporter record demo-session -- mcporter call linear.list_issues limit:5
mcporter replay demo-session -- mcporter call linear.list_issues limit:5
```
Recordings contain raw JSON-RPC params and responses. Review and redact them before sharing, attaching to bug reports, or committing them to a repository because tool arguments and results can include credentials, private content, or customer data.
To record or replay a later command, create the session configuration and export the matching environment variable:
```bash
mcporter record demo-session
MCPORTER_RECORD=demo-session mcporter call linear.list_issues limit:5
mcporter replay demo-session
MCPORTER_REPLAY=demo-session mcporter call linear.list_issues limit:5
```
Use `--server` when you only want one server's traffic:
```bash
mcporter record demo-session --server linear -- mcporter call linear.list_issues limit:5
mcporter replay demo-session --server linear -- mcporter call linear.list_issues limit:5
```
## File format
Each line is one JSON-RPC envelope with an added `_meta` object:
```json
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_issues","arguments":{"limit":5}},"_meta":{"dir":"send","server":"linear","ts":"2026-05-16T12:00:00.000Z"}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"..."}]},"_meta":{"dir":"recv","server":"linear","ts":"2026-05-16T12:00:00.100Z"}}
```
`_meta.dir` is `send`, `recv`, or `lifecycle`. Replay strips `_meta` before delivering a response. Lifecycle events such as transport start and close are recorded for diagnostics but ignored during replay.
## Deterministic matching
Replay is strict. For each server, mcporter expects requests to arrive in the same order with the same JSON-RPC method and deeply equal `params`. If the next request differs, replay fails with an error that names the incoming request and the next recorded request it expected.
This makes recordings useful as reproducible bug fixtures: a replay either follows the captured MCP exchange exactly or fails at the first point where the workflow diverges.

View File

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

View File

@ -1,170 +0,0 @@
#!/usr/bin/env tsx
/**
* Demonstration: `disableOAuth: true` provides cache-friendly OAuth
* suppression for headless callers.
*
* Spins up a local mock MCP server (no real auth), then exercises three
* patterns side-by-side and counts the distinct ClientContext objects
* the runtime hands out:
*
* 1. Legacy `maxOAuthAttempts: 0` uncached (existing contract).
* 2. `disableOAuth: true` direct connects pooled.
* 3. The documented headless setup pre-connect with
* `disableOAuth: true`, then 5 `callTool` invocations. Verifies the
* pre-connected slot is preserved (no implicit eviction).
*
* Run: pnpm tsx examples/headless-pooling-demo.ts
*
* Counting strategy: ClientContext object identity. Each call to
* `createClientContext` inside the runtime returns a fresh object;
* cached calls return the same object. We track the set of unique
* objects and report cardinality.
*/
import type { Server as HttpServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
import { createRuntime } from '../src/index.js';
const INVOCATIONS = 5;
async function startMockServer(): Promise<{ baseUrl: URL; httpServer: HttpServer }> {
const app = express();
app.use(express.json());
const mcp = new McpServer({ name: 'demo', version: '1.0.0' });
mcp.registerTool(
'add',
{
title: 'Addition',
description: 'Add two numbers',
inputSchema: { a: z.number(), b: z.number() },
outputSchema: { result: z.number() },
},
async ({ a, b }) => {
const result = { result: a + b };
return {
content: [{ type: 'text', text: JSON.stringify(result) }],
structuredContent: result,
};
}
);
app.get('/mcp', (_req, res) => res.sendStatus(405));
app.post('/mcp', async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on('close', () => {
transport.close().catch(() => {});
});
await mcp.connect(transport);
await transport.handleRequest(req, res, req.body);
});
const httpServer = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve, reject) => {
httpServer.once('listening', resolve);
httpServer.once('error', reject);
});
const address = httpServer.address() as AddressInfo;
return { baseUrl: new URL(`http://127.0.0.1:${address.port}/mcp`), httpServer };
}
async function main(): Promise<void> {
// The mock MCP server below has no `auth: 'oauth'` definition, so the
// OAuth flow is not exercised here. This demo focuses on the
// cache-behavior fix (the main fix in PR #198). OAuth-suppression
// semantics under `disableOAuth: true` are exercised by the unit
// tests in `tests/runtime-transport.test.ts` (shouldEstablishOAuth)
// and `tests/runtime-integration.test.ts` (cache + eviction).
const { baseUrl, httpServer } = await startMockServer();
console.log(`[demo] Mock MCP server listening at ${baseUrl}\n`);
try {
// ----- Pattern A: legacy maxOAuthAttempts: 0 (uncached) ------------
{
const runtime = await createRuntime({
servers: [
{
name: 'demo',
description: 'Demo server',
command: { kind: 'http', url: baseUrl },
},
],
});
const contexts = new Set<unknown>();
for (let i = 0; i < INVOCATIONS; i++) {
contexts.add(await runtime.connect('demo', { maxOAuthAttempts: 0 }));
}
console.log(`[demo] Pattern A — legacy maxOAuthAttempts: 0`);
console.log(`[demo] ${INVOCATIONS} connect() calls → ${contexts.size} distinct ClientContexts`);
console.log(`[demo] Expected: ${INVOCATIONS} (legacy contract: cache disabled when maxOAuthAttempts is set)`);
console.log(`[demo] Result: ${contexts.size === INVOCATIONS ? 'OK' : 'UNEXPECTED'}\n`);
await runtime.close();
}
// ----- Pattern B: disableOAuth: true on every connect ---------------
{
const runtime = await createRuntime({
servers: [
{
name: 'demo',
description: 'Demo server',
command: { kind: 'http', url: baseUrl },
},
],
});
const contexts = new Set<unknown>();
for (let i = 0; i < INVOCATIONS; i++) {
contexts.add(await runtime.connect('demo', { disableOAuth: true }));
}
console.log(`[demo] Pattern B — disableOAuth: true on every connect`);
console.log(`[demo] ${INVOCATIONS} connect() calls → ${contexts.size} distinct ClientContexts`);
console.log(`[demo] Expected: 1 (cache reuse under cache-friendly suppression)`);
console.log(`[demo] Result: ${contexts.size === 1 ? 'PASS' : 'FAIL'}\n`);
await runtime.close();
}
// ----- Pattern C: documented headless setup + 5 callTool ------------
{
const runtime = await createRuntime({
servers: [
{
name: 'demo',
description: 'Demo server',
command: { kind: 'http', url: baseUrl },
},
],
});
const initial = await runtime.connect('demo', { disableOAuth: true });
let sum = 0;
for (let i = 0; i < INVOCATIONS; i++) {
const result = (await runtime.callTool('demo', 'add', {
args: { a: i, b: i + 1 },
})) as { structuredContent?: { result: number } };
sum += result.structuredContent?.result ?? 0;
}
const afterCalls = await runtime.connect('demo', { disableOAuth: true });
const reused = afterCalls === initial;
console.log(`[demo] Pattern C — pre-connect(disableOAuth:true) + ${INVOCATIONS} callTool()`);
console.log(`[demo] Sum of ${INVOCATIONS} add() results: ${sum}`);
console.log(`[demo] Post-callTool connect() === pre-connect ClientContext: ${reused}`);
console.log(`[demo] Expected: true (no implicit eviction from callTool internals)`);
console.log(`[demo] Result: ${reused ? 'PASS' : 'FAIL'}\n`);
await runtime.close();
}
} finally {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -1,6 +1,6 @@
{
"name": "mcporter",
"version": "0.12.2",
"version": "0.11.3",
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
"keywords": [
"cli",
@ -61,42 +61,36 @@
"docs:site": "node scripts/build-docs-site.mjs",
"generate:schema": "tsx scripts/generate-json-schema.ts",
"mcporter:list": "pnpm exec tsx src/cli.ts list",
"mcporter:call": "pnpm exec tsx src/cli.ts call",
"check:changed": "pnpm run check",
"test:changed": "pnpm run test",
"crabbox:hydrate": "crabbox actions hydrate",
"crabbox:run": "crabbox run",
"crabbox:stop": "crabbox stop",
"crabbox:warmup": "crabbox warmup"
"mcporter:call": "pnpm exec tsx src/cli.ts call"
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.29.0",
"acorn": "^8.17.0",
"commander": "^15.0.0",
"es-toolkit": "^1.48.1",
"acorn": "^8.16.0",
"commander": "^14.0.3",
"es-toolkit": "^1.46.1",
"jsonc-parser": "^3.3.1",
"ora": "^9.4.1",
"rolldown": "1.1.2",
"ora": "^9.4.0",
"rolldown": "1.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/estree": "^1.0.9",
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"@typescript/native-preview": "7.0.0-dev.20260623.1",
"@vitest/coverage-v8": "^4.1.9",
"@types/node": "^25.8.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@vitest/coverage-v8": "^4.1.6",
"bun-types": "^1.3.14",
"cross-env": "^10.1.0",
"express": "^5.2.1",
"oxfmt": "^0.56.0",
"oxlint": "^1.71.0",
"oxlint-tsgolint": "^0.23.0",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"oxlint-tsgolint": "^0.22.1",
"rimraf": "^6.1.3",
"tsx": "^4.22.4",
"tsx": "^4.22.0",
"typescript": "^6.0.3",
"vite": "8.0.16",
"vitest": "^4.1.9"
"vite": "8.0.13",
"vitest": "^4.1.6"
},
"devEngines": {
"runtime": [

1427
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,5 @@ 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.16
vite: 8.0.13

View File

@ -74,26 +74,16 @@ 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;
}
}
throw embeddedError;
return await readMetadataFromCli(artifactPath);
}
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {

View File

@ -4,7 +4,6 @@ import { inferCommandRouting } from './cli/command-inference.js';
import { CliUsageError } from './cli/errors.js';
import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js';
import { logError, logInfo } from './cli/logger-context.js';
import { isRecordReplayModeActive, isReplayModeActive } from './cli/record-replay-env.js';
import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
import { resolveConfigPath } from './config/path-discovery.js';
import type { Runtime, RuntimeOptions } from './runtime.js';
@ -14,52 +13,8 @@ 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> {
@ -199,28 +154,6 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
if (command === 'record') {
const { handleRecordCli, printRecordHelp } = await import('./cli/record-command.js');
if (consumeHelpTokens(wrapperArgsBeforeSeparator(args))) {
printRecordHelp();
process.exitCode = 0;
return;
}
await handleRecordCli(args);
return;
}
if (command === 'replay') {
const { handleReplayCli, printReplayHelp } = await import('./cli/replay-command.js');
if (consumeHelpTokens(wrapperArgsBeforeSeparator(args))) {
printReplayHelp();
process.exitCode = 0;
return;
}
await handleReplayCli(args);
return;
}
if (command === 'config') {
const { handleConfigCli } = await import('./cli/config-command.js');
await handleConfigCli(
@ -264,17 +197,14 @@ export async function runCli(argv: string[]): Promise<void> {
import('./lifecycle.js'),
]);
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
const recordReplayModeActive = isRecordReplayModeActive();
const keepAliveServers = recordReplayModeActive
? new Set<string>()
: new Set(
baseRuntime
.getDefinitions()
.filter(isKeepAliveServer)
.map((entry) => entry.name)
);
const keepAliveServers = new Set(
baseRuntime
.getDefinitions()
.filter(isKeepAliveServer)
.map((entry) => entry.name)
);
const daemonClient =
!recordReplayModeActive && keepAliveServers.size > 0
keepAliveServers.size > 0
? new DaemonClient({
configPath: configResolution.path,
configExplicit: configResolution.explicit,
@ -283,16 +213,15 @@ export async function runCli(argv: string[]): Promise<void> {
: null;
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
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;
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {
process.exitCode = inference.exitCode;
return;
}
const resolvedCommand = inference.command;
const resolvedArgs = inference.args;
try {
if (resolvedCommand === 'list') {
if (consumeHelpTokens(resolvedArgs)) {
const { printListHelp } = await import('./cli/list-command.js');
@ -352,67 +281,46 @@ 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 });
}
}
async function closeRuntimeAfterCommand(
runtime: Runtime,
options: { readonly suppressReplayCloseError?: boolean } = {}
): Promise<void> {
const closeStart = Date.now();
let closeError: unknown;
if (DEBUG_HANG) {
logInfo('[debug] beginning runtime.close()');
dumpActiveHandles('before runtime.close');
}
try {
await runtime.close();
const closeStart = Date.now();
if (DEBUG_HANG) {
const duration = Date.now() - closeStart;
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
dumpActiveHandles('after runtime.close');
logInfo('[debug] beginning runtime.close()');
dumpActiveHandles('before runtime.close');
}
} catch (error) {
if (DEBUG_HANG) {
logError('[debug] runtime.close() failed', error);
}
if (isReplayModeActive() && !options.suppressReplayCloseError) {
closeError = error;
}
} 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';
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
const scheduleForcedExit = () => {
if (shouldForceExit) {
setTimeout(flushStdioThenForceExit, FORCE_EXIT_GRACE_MS);
try {
await runtime.close();
if (DEBUG_HANG) {
const duration = Date.now() - closeStart;
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
dumpActiveHandles('after runtime.close');
}
} catch (error) {
if (DEBUG_HANG) {
logError('[debug] runtime.close() failed', error);
}
} 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';
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
const scheduleForcedExit = () => {
if (shouldForceExit) {
setTimeout(() => {
process.exit(process.exitCode ?? 0);
}, FORCE_EXIT_GRACE_MS);
}
};
if (DEBUG_HANG) {
dumpActiveHandles('after terminateChildProcesses');
scheduleForcedExit();
} else {
setImmediate(scheduleForcedExit);
}
};
if (DEBUG_HANG) {
dumpActiveHandles('after terminateChildProcesses');
scheduleForcedExit();
} else {
setImmediate(scheduleForcedExit);
}
}
if (closeError) {
throw closeError;
}
}
function wrapperArgsBeforeSeparator(args: readonly string[]): string[] {
const separatorIndex = args.indexOf('--');
return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex);
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
}
// main parses CLI flags and dispatches to list/call commands.
@ -421,7 +329,6 @@ async function main(): Promise<void> {
}
if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
installStdioErrorHandlers();
main().catch((error) => {
if (error instanceof CliUsageError) {
logError(error.message);
@ -453,9 +360,6 @@ async function maybeHandleDaemonFastCall(
configResolution: { path: string; explicit: boolean },
rootDir: string | undefined
): Promise<boolean> {
if (isRecordReplayModeActive()) {
return false;
}
const callArgs = resolveDaemonFastCallArgs(command, args);
if (!callArgs) {
return false;
@ -523,7 +427,6 @@ async function maybeHandleSimpleDaemonFastCall(
tool: parsed.tool,
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
disableOAuth: parsed.disableOAuth,
});
const { callResult } = wrapCallResult(result);
printCallOutput(callResult, result, parsed.output);
@ -551,8 +454,6 @@ function isExplicitNonCallCommand(command: string): boolean {
command === 'resources' ||
command === 'daemon' ||
command === 'serve' ||
command === 'record' ||
command === 'replay' ||
command === 'config' ||
command === 'emit-ts' ||
command === 'generate-cli' ||
@ -628,8 +529,6 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
server,
includeSchema: options?.includeSchema,
autoAuthorize: options?.autoAuthorize,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
})) as Awaited<ReturnType<Runtime['listTools']>>,
callTool: (server, toolName, options) =>
daemonClient.callTool({
@ -637,27 +536,9 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
tool: toolName,
args: options?.args,
timeoutMs: options?.timeoutMs,
disableOAuth: options?.disableOAuth,
}),
listResources: (server, options) => {
const params: Record<string, unknown> = { ...options };
delete params.allowCachedAuth;
delete params.disableOAuth;
delete params.oauthSessionOptions;
return daemonClient.listResources({
server,
params,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
});
},
readResource: (server, uri, options) =>
daemonClient.readResource({
server,
uri,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
}),
listResources: (server, options) => daemonClient.listResources({ server, params: options ?? {} }),
readResource: (server, uri) => daemonClient.readResource({ server, uri }),
connect: async (server) => {
throw new Error(`Server '${server}' is only available through daemon request methods.`);
},

View File

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

View File

@ -25,7 +25,6 @@ export interface CallArgsParseResult {
tailLog: boolean;
output: OutputFormat;
timeoutMs?: number;
disableOAuth?: boolean;
ephemeral?: EphemeralServerSpec;
rawStrings?: boolean;
saveImagesDir?: string;
@ -60,7 +59,6 @@ const FLAG_HANDLERS: Record<string, FlagHandler> = {
'--tool': handleToolFlag,
'--timeout': handleTimeoutFlag,
'--tail-log': handleTailLogFlag,
'--no-oauth': handleDisableOAuthFlag,
'--save-images': handleSaveImagesFlag,
'--yes': handleNoopFlag,
'--raw-strings': handleRawStringsFlag,
@ -193,7 +191,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
continue;
}
index += parsed.consumed;
const { value, schemaValue } = resolveNamedArgumentValue(parsed.rawValue, state.coercionMode);
const value = coerceValue(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 +208,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
}
if (state.coercionMode === 'default' && typeof value === 'number') {
result.schemaStringCoercionCandidates ??= {};
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
}
result.args[parsed.key] = value;
}
@ -258,11 +256,6 @@ function handleTailLogFlag(context: FlagHandlerContext): number {
return context.index + 1;
}
function handleDisableOAuthFlag(context: FlagHandlerContext): number {
context.result.disableOAuth = true;
return context.index + 1;
}
function handleSaveImagesFlag(context: FlagHandlerContext): number {
context.result.saveImagesDir = consumeFlagValue(
context.args,
@ -327,53 +320,18 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
eqIndex === -1
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
: body.slice(eqIndex + 1);
const { value, schemaValue } = resolveNamedArgumentValue(rawValue, context.state.coercionMode);
const value = coerceValue(rawValue, context.state.coercionMode);
if (context.state.coercionMode === 'default' && typeof value === 'number') {
context.result.schemaStringCoercionCandidates ??= {};
context.result.schemaStringCoercionCandidates[key] = schemaValue;
context.result.schemaStringCoercionCandidates[key] = rawValue;
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
context.result.schemaArrayCoercionCandidates ??= {};
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
context.result.schemaArrayCoercionCandidates[key] = rawValue;
}
context.result.args[key] = value;
return context.index + (eqIndex === -1 ? 2 : 1);
}
function resolveNamedArgumentValue(
rawValue: string,
coercionMode: CoercionMode
): { value: unknown; schemaValue: string } {
if (rawValue.startsWith('@@')) {
const literal = rawValue.slice(1);
return { value: literal, schemaValue: literal };
}
if (rawValue.length > 0 && rawValue.trim() === '') {
return { value: rawValue, schemaValue: rawValue };
}
if (!rawValue.startsWith('@')) {
return { value: coerceValue(rawValue, coercionMode), schemaValue: rawValue };
}
const filePath = rawValue.slice(1);
if (!filePath) {
throw new CliUsageError("Argument file reference '@' requires a path. Use '@@' for a literal leading '@'.");
}
let contents: Buffer;
try {
contents = fs.readFileSync(filePath);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new CliUsageError(`Unable to read argument file '${filePath}': ${detail}`);
}
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(contents);
return { value: text, schemaValue: text };
} catch {
throw new CliUsageError(`Argument file '${filePath}' is not valid UTF-8 text.`);
}
}
function normalizeLongFlagArgumentKey(rawKey: string): string {
if (!rawKey || rawKey.startsWith('-')) {
return '';

View File

@ -39,7 +39,6 @@ interface PreparedCallRequest extends ResolvedCallTarget {
parsed: CallArgsParseResult;
hydratedArgs: Record<string, unknown>;
timeoutMs: number;
disableOAuth?: boolean;
ephemeralTarget?: PrepareEphemeralServerTargetResult;
}
@ -67,19 +66,12 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
const { server, tool } = await resolveServerAndTool(runtime, parsed);
if (await maybeDescribeServer(runtime, server, tool, parsed.output, parsed.disableOAuth)) {
if (await maybeDescribeServer(runtime, server, tool, parsed.output)) {
return undefined;
}
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const hydratedArgs = await hydratePositionalArguments(
runtime,
server,
tool,
parsed.args,
parsed.positionalArgs,
parsed.disableOAuth
);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
runtime,
server,
@ -87,18 +79,9 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
hydratedArgs,
parsed.schemaStringCoercionCandidates,
parsed.schemaArrayCoercionCandidates,
timeoutMs,
parsed.disableOAuth
timeoutMs
);
return {
parsed,
server,
tool,
hydratedArgs: schemaAwareArgs,
timeoutMs,
disableOAuth: parsed.disableOAuth,
ephemeralTarget,
};
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs, ephemeralTarget };
}
async function normalizeParsedCallArguments(
@ -131,18 +114,10 @@ async function normalizeParsedCallArguments(
parsed.server = undefined;
}
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);
}
if (ephemeralSpec?.httpUrl && !ephemeralSpec.name && parsed.tool) {
const candidate = parsed.selector && !looksLikeHttpUrl(parsed.selector) ? parsed.selector : undefined;
if (candidate) {
nameHints.push(candidate);
parsed.selector = undefined;
}
}
@ -170,7 +145,7 @@ async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResul
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
}
if (!tool) {
tool = await inferSingleToolName(runtime, server, parsed.disableOAuth);
tool = await inferSingleToolName(runtime, server);
if (!tool) {
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
}
@ -190,8 +165,7 @@ async function invokePreparedCall(
prepared.tool,
prepared.hydratedArgs,
prepared.timeoutMs,
prepared.parsed.output,
prepared.disableOAuth
prepared.parsed.output
);
} catch (error) {
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
@ -250,15 +224,11 @@ async function maybeDescribeServer(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: string,
tool: string,
outputFormat: OutputFormat,
disableOAuth: boolean | undefined
outputFormat: OutputFormat
): Promise<boolean> {
if (tool === 'list_tools') {
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
const listArgs = [server];
if (disableOAuth) {
listArgs.push('--no-oauth');
}
if (outputFormat === 'json') {
listArgs.push('--json');
}
@ -269,9 +239,7 @@ async function maybeDescribeServer(
if (tool !== 'help') {
return false;
}
const tools = await runtime
.listTools(server, { includeSchema: false, autoAuthorize: false, disableOAuth })
.catch(() => undefined);
const tools = await runtime.listTools(server, { includeSchema: false, autoAuthorize: false }).catch(() => undefined);
if (!tools) {
return false;
}
@ -281,9 +249,6 @@ async function maybeDescribeServer(
}
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
const listArgs = [server];
if (disableOAuth) {
listArgs.push('--no-oauth');
}
if (outputFormat === 'json') {
listArgs.push('--json');
}
@ -324,17 +289,6 @@ 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,
@ -342,8 +296,7 @@ async function enforceSchemaAwareArgumentTypes(
args: Record<string, unknown>,
stringCandidates: Record<string, string> | undefined,
arrayCandidates: Record<string, string> | undefined,
timeoutMs: number,
disableOAuth: boolean | undefined
timeoutMs: number
): Promise<Record<string, unknown>> {
if (
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
@ -352,10 +305,9 @@ async function enforceSchemaAwareArgumentTypes(
return args;
}
const tools = await withTimeout(
loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }),
timeoutMs
).catch(() => undefined);
const tools = await withTimeout(loadToolMetadata(runtime, server, { includeSchema: true }), timeoutMs).catch(
() => undefined
);
if (!tools) {
return args;
}
@ -437,15 +389,14 @@ async function hydratePositionalArguments(
server: string,
tool: string,
namedArgs: Record<string, unknown>,
positionalArgs: unknown[] | undefined,
disableOAuth: boolean | undefined
positionalArgs: unknown[] | undefined
): Promise<Record<string, unknown>> {
if (!positionalArgs || positionalArgs.length === 0) {
return namedArgs;
}
// We need the schema order to know which field each positional argument maps to; pull the
// tool list with schemas instead of guessing locally so optional/required order stays correct.
const tools = await loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }).catch(() => undefined);
const tools = await loadToolMetadata(runtime, server, { includeSchema: true }).catch(() => undefined);
if (!tools) {
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
}
@ -485,10 +436,9 @@ type ToolResolution = IdentifierResolution;
async function inferSingleToolName(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: string,
disableOAuth: boolean | undefined
server: string
): Promise<string | undefined> {
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth });
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
if (tools.length !== 1) {
return undefined;
}
@ -506,11 +456,10 @@ async function invokeWithAutoCorrection(
tool: string,
args: Record<string, unknown>,
timeoutMs: number,
outputFormat: OutputFormat,
disableOAuth: boolean | undefined
outputFormat: OutputFormat
): Promise<{ result: unknown; resolvedTool: string }> {
// Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match.
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true, disableOAuth);
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true);
}
async function attemptCall(
@ -520,24 +469,14 @@ async function attemptCall(
args: Record<string, unknown>,
timeoutMs: number,
outputFormat: OutputFormat,
allowCorrection: boolean,
disableOAuth: boolean | undefined
allowCorrection: boolean
): Promise<{ result: unknown; resolvedTool: string }> {
try {
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs, disableOAuth }), timeoutMs);
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs);
if (allowCorrection && isErrorCallResult(result)) {
const resolution = await maybeResolveToolName(runtime, server, tool, result, disableOAuth);
const resolution = await maybeResolveToolName(runtime, server, tool, result);
if (resolution) {
const retry = await maybeRetryResolvedTool(
runtime,
server,
tool,
args,
timeoutMs,
outputFormat,
resolution,
disableOAuth
);
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
if (retry) {
return retry;
}
@ -558,22 +497,13 @@ async function attemptCall(
throw error;
}
const resolution = await maybeResolveToolName(runtime, server, tool, error, disableOAuth);
const resolution = await maybeResolveToolName(runtime, server, tool, error);
if (!resolution) {
maybeReportConnectionIssue(server, tool, error);
throw error;
}
const retry = await maybeRetryResolvedTool(
runtime,
server,
tool,
args,
timeoutMs,
outputFormat,
resolution,
disableOAuth
);
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
if (!retry) {
throw error;
}
@ -588,8 +518,7 @@ async function maybeRetryResolvedTool(
args: Record<string, unknown>,
timeoutMs: number,
outputFormat: OutputFormat,
resolution: ToolResolution,
disableOAuth: boolean | undefined
resolution: ToolResolution
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
const messages = renderIdentifierResolutionMessages({
entity: 'tool',
@ -607,15 +536,14 @@ async function maybeRetryResolvedTool(
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
emitAutoMessage(dimText(messages.auto));
}
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false, disableOAuth);
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false);
}
async function maybeResolveToolName(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: string,
attemptedTool: string,
error: unknown,
disableOAuth: boolean | undefined
error: unknown
): Promise<ToolResolution | undefined> {
const missingName = extractMissingToolFromError(error);
if (!missingName) {
@ -627,7 +555,7 @@ async function maybeResolveToolName(
return undefined;
}
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth }).catch(() => undefined);
const tools = await loadToolMetadata(runtime, server, { includeSchema: false }).catch(() => undefined);
if (!tools) {
return undefined;
}

View File

@ -1,6 +1,5 @@
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.',
@ -11,7 +10,6 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
' --timeout <ms> Override the call timeout.',
' --output text|markdown|json|raw Control formatting.',
' --save-images <dir> Save image content blocks to a directory.',
' --no-oauth Never start OAuth; use cached tokens only.',
' --raw-strings Keep numeric-looking argument values as strings.',
' --no-coerce Keep all key/value and positional arguments as raw strings.',
' --tail-log Stream returned log handles.',
@ -33,7 +31,6 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
export const CALL_HELP_EXAMPLE_LINES = [
' mcporter call linear.list_issues team=ENG limit:5',
' mcporter call linear.create_comment body=@comment.md',
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',

View File

@ -2,7 +2,6 @@ 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>;
@ -30,8 +29,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
let oauthTimeoutOverride: number | undefined;
if (globalFlags['--oauth-timeout']) {
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
if (parsed === undefined) {
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
return { exit: true, code: 1 };
}

View File

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

View File

@ -6,9 +6,6 @@ export function extractFlags(args: string[], keys: readonly string[]): FlagMap {
let index = 0;
while (index < args.length) {
const token = args[index];
if (token === '--') {
break;
}
if (token === undefined || !keys.includes(token)) {
index += 1;
continue;

View File

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

View File

@ -101,7 +101,6 @@ export function renderTemplate({
tool: entry.tool,
})
);
assertUniqueGeneratedCommandNames(renderedTools);
const toolHelp = renderedTools.map((entry) => ({
name: entry.commandName,
description: entry.tool.tool.description ?? '',
@ -238,7 +237,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) => parseFiniteNumber(entry));
\t\treturn values.map((entry) => parseFloat(entry));
\t}
\tif (itemType === 'boolean') {
\t\treturn values.map((entry) => entry !== 'false');
@ -246,15 +245,6 @@ 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') {
@ -472,9 +462,7 @@ export function renderToolCommand(
({ option, camelCaseProp }) =>
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
)
.join(
', '
)}].filter((entry) => entry.value === undefined || (typeof entry.value === 'string' && entry.value.trim() === '')).map((entry) => entry.flag);
.join(', ')}].filter((entry) => entry.value === undefined).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}`
@ -561,7 +549,7 @@ export const templateTestHelpers = { computeRelativeStdioCwd };
function optionParser(option: GeneratedOption): string | undefined {
switch (option.type) {
case 'number':
return '(value) => parseFiniteNumber(value)';
return '(value) => parseFloat(value)';
case 'boolean':
return "(value) => value !== 'false'";
case 'object':
@ -582,16 +570,3 @@ function optionParser(option: GeneratedOption): string | undefined {
return undefined;
}
}
function assertUniqueGeneratedCommandNames(tools: Array<{ commandName: string; tool: ToolMetadata }>): void {
const commands = new Map<string, string>();
for (const entry of tools) {
const previous = commands.get(entry.commandName);
if (previous) {
throw new Error(
`Generated command name collision '${entry.commandName}' for tools '${previous}' and '${entry.tool.tool.name}'.`
);
}
commands.set(entry.commandName, entry.tool.tool.name);
}
}

View File

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

View File

@ -72,16 +72,6 @@ function buildCommandSections(colorize: boolean): string[] {
summary: 'Seed or clear OAuth credentials non-interactively',
usage: 'mcporter vault set <server> --tokens-file <path>',
},
{
name: 'record',
summary: 'Capture MCP JSON-RPC traffic to NDJSON',
usage: 'mcporter record <session-name> [--server <name>] [-- <command>]',
},
{
name: 'replay',
summary: 'Replay recorded MCP JSON-RPC traffic deterministically',
usage: 'mcporter replay <session-name> [--server <name>] [-- <command>]',
},
],
},
{

View File

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

View File

@ -98,7 +98,7 @@ export async function handleList(
let completedCount = 0;
const tasks = servers.map((server, index) =>
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
checkListServer(runtime, server, perServerTimeoutMs).then((result) => {
summaryResults[index] = result;
if (renderedResults) {
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
@ -175,7 +175,7 @@ export async function handleList(
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
if (!resolved) {
process.exitCode = 1;
maybeSetListExitCode([{ status: 'error' }], flags);
return;
}
target = resolved.name;
@ -190,7 +190,7 @@ export async function handleList(
if (flags.statusOnly) {
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
try {
const result = await checkListServer(runtime, definition, timeoutMs, flags.disableOAuth);
const result = await checkListServer(runtime, definition, timeoutMs);
await persistPreparedEphemeralServer(runtime, prepared);
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
includeSchemas: false,
@ -228,7 +228,6 @@ export async function handleList(
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: flags.disableOAuth,
}),
timeoutMs
),
@ -299,7 +298,6 @@ export async function handleList(
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: flags.disableOAuth,
}),
timeoutMs
),
@ -399,13 +397,12 @@ export async function handleList(
async function checkListServer(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: ServerDefinition,
timeoutMs: number,
disableOAuth: boolean
timeoutMs: number
): Promise<ListSummaryResult> {
const startedAt = Date.now();
try {
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true, disableOAuth }),
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
timeoutMs
);
return {
@ -486,7 +483,6 @@ export function printListHelp(): void {
' --verbose Show all config sources for matching servers.',
' --sources Include source arrays in JSON output without other verbose details.',
' --timeout <ms> Override the per-server discovery timeout.',
' --no-oauth Never start OAuth; use cached tokens only.',
'',
'Examples:',
' mcporter list',

View File

@ -17,7 +17,6 @@ export function extractListFlags(args: string[]): {
quiet: boolean;
exitCode: boolean;
statusOnly: boolean;
disableOAuth: boolean;
} {
let schema = false;
let timeoutMs: number | undefined;
@ -28,7 +27,6 @@ export function extractListFlags(args: string[]): {
let quiet = false;
let exitCode = false;
let statusOnly = false;
let disableOAuth = false;
const format = consumeOutputFormat(args, {
defaultFormat: 'text',
allowed: ['text', 'json'],
@ -84,11 +82,6 @@ export function extractListFlags(args: string[]): {
args.splice(index, 1);
continue;
}
if (token === '--no-oauth') {
disableOAuth = true;
args.splice(index, 1);
continue;
}
if (token === '--timeout') {
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
continue;
@ -140,6 +133,5 @@ export function extractListFlags(args: string[]): {
quiet,
exitCode,
statusOnly,
disableOAuth,
};
}

View File

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

View File

@ -1,5 +1,4 @@
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';
@ -34,8 +33,17 @@ 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'];
const possibleKeys = ['logPath', 'logFile', 'logfile', 'path'];
for (const key of possibleKeys) {
const value = (result as Record<string, unknown>)[key];
if (typeof value === 'string') {
@ -45,10 +53,6 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
}
for (const candidate of candidates) {
if (!path.isAbsolute(candidate)) {
logWarn(`Refusing to tail non-absolute log path: ${candidate}`);
continue;
}
if (!fs.existsSync(candidate)) {
logWarn(`Log path not found: ${candidate}`);
continue;

View File

@ -1,150 +0,0 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import {
ensurePrivateRecordingDir,
PRIVATE_RECORDING_FILE_MODE,
resolveRecordingConfigPath,
resolveRecordingPath,
} from '../runtime/record-transport.js';
import { buildRecordCommandEnv } from './record-replay-env.js';
export interface ParsedRecordArgs {
readonly sessionName: string;
readonly server?: string;
readonly command: string[];
}
export async function handleRecordCli(args: string[]): Promise<void> {
const parsed = parseRecordArgs(args);
const recordPath = resolveRecordingPath(parsed.sessionName);
if (parsed.command.length > 0) {
await runWithRecordingEnv(parsed, buildRecordCommandEnv(parsed.sessionName, parsed.server));
return;
}
await writeModeConfig(parsed, {
mode: 'record',
recordPath,
env: {
MCPORTER_RECORD: parsed.sessionName,
...(parsed.server ? { MCPORTER_RECORD_SERVER: parsed.server } : {}),
MCPORTER_DISABLE_KEEPALIVE: '*',
},
});
console.log(`Recording configuration written to ${resolveRecordingConfigPath(parsed.sessionName)}`);
const envInstructions = [
`MCPORTER_RECORD=${parsed.sessionName}`,
...(parsed.server ? [`MCPORTER_RECORD_SERVER=${parsed.server}`] : []),
'MCPORTER_DISABLE_KEEPALIVE=*',
];
console.log(`Set ${envInstructions.join(' and ')} before the next mcporter call to record ${recordPath}.`);
}
export function printRecordHelp(): void {
console.log(`Usage: mcporter record <session-name> [--server <name>] [-- <command-to-run>]
Capture MCP JSON-RPC traffic to ~/.mcporter/recordings/<session-name>.ndjson.
Flags:
--server <name> Restrict recording to one configured server.`);
}
export function parseRecordArgs(args: string[]): ParsedRecordArgs {
return parseSessionCommandArgs(args, 'record');
}
export function parseReplayArgs(args: string[]): ParsedRecordArgs {
return parseSessionCommandArgs(args, 'replay');
}
async function writeModeConfig(parsed: ParsedRecordArgs, extra: Record<string, unknown>): Promise<void> {
const configPath = resolveRecordingConfigPath(parsed.sessionName);
await ensurePrivateRecordingDir(configPath);
await fs.writeFile(
configPath,
`${JSON.stringify(
{
session: parsed.sessionName,
server: parsed.server,
...extra,
},
null,
2
)}\n`,
{
encoding: 'utf8',
mode: PRIVATE_RECORDING_FILE_MODE,
}
);
await fs.chmod(configPath, PRIVATE_RECORDING_FILE_MODE);
}
async function runWithRecordingEnv(parsed: ParsedRecordArgs, env: NodeJS.ProcessEnv): Promise<void> {
const [command, ...commandArgs] = parsed.command;
if (!command) {
return;
}
await new Promise<void>((resolve, reject) => {
const child = spawn(command, commandArgs, {
stdio: 'inherit',
env,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (signal) {
reject(new Error(`Command '${command}' exited from signal ${signal}.`));
return;
}
process.exitCode = code ?? 0;
resolve();
});
});
}
function parseSessionCommandArgs(args: string[], commandName: 'record' | 'replay'): ParsedRecordArgs {
let server: string | undefined;
const tokens = [...args];
const commandSeparator = tokens.indexOf('--');
const command = commandSeparator === -1 ? [] : tokens.splice(commandSeparator);
if (command[0] === '--') {
command.shift();
}
const remaining: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
if (token === '--server') {
const value = tokens[index + 1];
if (!value) {
throw new Error("Flag '--server' requires a server name.");
}
server = value;
index += 1;
continue;
}
if (token.startsWith('--server=')) {
server = token.slice('--server='.length);
if (!server) {
throw new Error("Flag '--server' requires a server name.");
}
continue;
}
if (token.startsWith('-')) {
throw new Error(`Unknown ${commandName} flag '${token}'.`);
}
remaining.push(token);
}
const sessionName = remaining[0];
if (!sessionName) {
throw new Error(`Usage: mcporter ${commandName} <session-name> [--server <name>] [-- <command-to-run>]`);
}
if (remaining.length > 1) {
throw new Error(`Unexpected ${commandName} argument '${remaining[1]}'. Put commands after '--'.`);
}
return { sessionName, server, command };
}

View File

@ -1,46 +0,0 @@
const KEEP_ALIVE_DISABLED_FOR_MODE = '*';
export function buildRecordCommandEnv(sessionName: string, server: string | undefined): NodeJS.ProcessEnv {
return buildModeEnv(
{
MCPORTER_RECORD: sessionName,
MCPORTER_RECORD_SERVER: server,
MCPORTER_DISABLE_KEEPALIVE: KEEP_ALIVE_DISABLED_FOR_MODE,
},
['MCPORTER_REPLAY', 'MCPORTER_REPLAY_SERVER']
);
}
export function buildReplayCommandEnv(sessionName: string, server: string | undefined): NodeJS.ProcessEnv {
return buildModeEnv(
{
MCPORTER_REPLAY: sessionName,
MCPORTER_REPLAY_SERVER: server,
MCPORTER_DISABLE_KEEPALIVE: KEEP_ALIVE_DISABLED_FOR_MODE,
},
['MCPORTER_RECORD', 'MCPORTER_RECORD_SERVER']
);
}
export function isRecordReplayModeActive(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.MCPORTER_RECORD || env.MCPORTER_REPLAY);
}
export function isReplayModeActive(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(!env.MCPORTER_RECORD && env.MCPORTER_REPLAY);
}
function buildModeEnv(set: Record<string, string | undefined>, unset: readonly string[]): NodeJS.ProcessEnv {
const env = { ...process.env };
for (const key of unset) {
delete env[key];
}
for (const [key, value] of Object.entries(set)) {
if (value) {
env[key] = value;
} else {
delete env[key];
}
}
return env;
}

View File

@ -1,84 +0,0 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import {
ensurePrivateRecordingDir,
PRIVATE_RECORDING_FILE_MODE,
resolveRecordingConfigPath,
resolveRecordingPath,
} from '../runtime/record-transport.js';
import { parseReplayArgs } from './record-command.js';
import { buildReplayCommandEnv } from './record-replay-env.js';
export async function handleReplayCli(args: string[]): Promise<void> {
const parsed = parseReplayArgs(args);
const replayPath = resolveRecordingPath(parsed.sessionName);
if (parsed.command.length > 0) {
await runWithReplayEnv(parsed.command, buildReplayCommandEnv(parsed.sessionName, parsed.server));
return;
}
const configPath = resolveRecordingConfigPath(parsed.sessionName);
await ensurePrivateRecordingDir(configPath);
await fs.writeFile(
configPath,
`${JSON.stringify(
{
session: parsed.sessionName,
server: parsed.server,
mode: 'replay',
replayPath,
env: {
MCPORTER_REPLAY: parsed.sessionName,
...(parsed.server ? { MCPORTER_REPLAY_SERVER: parsed.server } : {}),
MCPORTER_DISABLE_KEEPALIVE: '*',
},
},
null,
2
)}\n`,
{
encoding: 'utf8',
mode: PRIVATE_RECORDING_FILE_MODE,
}
);
await fs.chmod(configPath, PRIVATE_RECORDING_FILE_MODE);
console.log(`Replay configuration written to ${configPath}`);
const envInstructions = [
`MCPORTER_REPLAY=${parsed.sessionName}`,
...(parsed.server ? [`MCPORTER_REPLAY_SERVER=${parsed.server}`] : []),
'MCPORTER_DISABLE_KEEPALIVE=*',
];
console.log(`Set ${envInstructions.join(' and ')} before the next mcporter call to replay ${replayPath}.`);
}
export function printReplayHelp(): void {
console.log(`Usage: mcporter replay <session-name> [--server <name>] [-- <command-to-run>]
Replay MCP JSON-RPC traffic from ~/.mcporter/recordings/<session-name>.ndjson.
Flags:
--server <name> Restrict replay to one configured server.`);
}
async function runWithReplayEnv(commandAndArgs: string[], env: NodeJS.ProcessEnv): Promise<void> {
const [command, ...args] = commandAndArgs;
if (!command) {
return;
}
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
env,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (signal) {
reject(new Error(`Command '${command}' exited from signal ${signal}.`));
return;
}
process.exitCode = code ?? 0;
resolve();
});
});
}

View File

@ -13,7 +13,6 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
enableRawShortcut: true,
jsonShortcutFlag: '--json',
});
const disableOAuth = consumeDisableOAuthFlag(args);
const server = args.shift();
if (!server) {
throw new Error('Missing server name. Usage: mcporter resource <server> [uri]');
@ -25,14 +24,7 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
let result: unknown;
try {
if (disableOAuth === undefined) {
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
} else {
const connectOptions = { disableOAuth };
result = uri
? await runtime.readResource(server, uri, connectOptions)
: await runtime.listResources(server, connectOptions);
}
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
} catch (error) {
const issue = analyzeConnectionError(error);
if (output === 'json' || output === 'raw') {
@ -47,20 +39,6 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
printCallOutput(callResult, result, output);
}
function consumeDisableOAuthFlag(args: string[]): boolean | undefined {
let disableOAuth: boolean | undefined;
for (let index = 0; index < args.length; ) {
const token = args[index];
if (token === '--no-oauth') {
disableOAuth = true;
args.splice(index, 1);
continue;
}
index += 1;
}
return disableOAuth;
}
export function printResourceHelp(): void {
console.error(
[
@ -73,7 +51,6 @@ export function printResourceHelp(): void {
' --output auto|text|markdown|json|raw Choose output rendering.',
' --json Shortcut for --output json.',
' --raw Shortcut for --output raw.',
' --no-oauth Never start OAuth; use cached tokens only.',
'',
'Examples:',
' mcporter resource docs',

View File

@ -93,7 +93,7 @@ Expose daemon-managed keep-alive MCP servers as one MCP server.
Flags:
--servers <csv> Restrict the bridge to the listed keep-alive server names.
--stdio Serve MCP over stdio (default).
--http <port> Serve MCP Streamable HTTP on /mcp and /mcp/<server>.
--http <port> Serve MCP Streamable HTTP on /mcp.
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
}

View File

@ -1,21 +1,16 @@
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;
}
return parsePositiveInteger(raw) ?? fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
@ -63,8 +58,8 @@ export function consumeTimeoutFlag(
if (!value) {
throw new Error(missingValueMessage);
}
const parsed = parsePositiveInteger(value);
if (parsed === undefined) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
}
args.splice(index, 2);

View File

@ -1,11 +1,10 @@
import type { ListToolsOptions, Runtime } from '../runtime.js';
import { buildToolMetadataList, type ToolMetadata } from './generate/tools.js';
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
interface LoadToolMetadataOptions {
includeSchema?: boolean;
autoAuthorize?: boolean;
allowCachedAuth?: boolean;
disableOAuth?: boolean;
}
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
@ -14,8 +13,7 @@ function cacheKey(serverName: string, options: LoadToolMetadataOptions): string
const includeSchema = options.includeSchema !== false;
const autoAuthorize = options.autoAuthorize !== false;
const allowCachedAuth = options.allowCachedAuth !== false;
const disableOAuth = options.disableOAuth === true;
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}::disable-oauth:${disableOAuth ? '1' : '0'}`;
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}`;
}
export async function loadToolMetadata(
@ -39,11 +37,10 @@ export async function loadToolMetadata(
includeSchema,
autoAuthorize,
allowCachedAuth: options.allowCachedAuth ?? true,
disableOAuth: options.disableOAuth,
};
const promise = runtime
.listTools(serverName, listOptions)
.then((tools) => buildToolMetadataList(tools, { sort: false }))
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
.catch((error) => {
cache?.delete(key);
throw error;

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { listConfigLayerPaths } from '../config/path-discovery.js';
import { withFileLock } from '../fs-json.js';
import { getDaemonMetadataPath, getDaemonSocketPath } from './paths.js';
import type {
CallToolParams,
@ -24,7 +23,6 @@ export interface DaemonClientOptions {
}
const DEFAULT_DAEMON_TIMEOUT_MS = 30_000;
const MIN_DAEMON_STATUS_TIMEOUT_MS = 1_000;
export interface DaemonPaths {
readonly key: string;
@ -85,7 +83,14 @@ export class DaemonClient {
}
async status(): Promise<StatusResult | null> {
return await this.readVerifiedStatus();
try {
return (await this.sendRequest<StatusResult>('status', {})) as StatusResult;
} catch (error) {
if (isTransportError(error)) {
return null;
}
throw error;
}
}
async stop(): Promise<void> {
@ -100,7 +105,7 @@ export class DaemonClient {
}
private async invoke<T = unknown>(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise<T> {
await this.ensureDaemon(timeoutMs);
await this.ensureDaemon();
try {
return (await this.sendRequest<T>(method, params, timeoutMs)) as T;
} catch (error) {
@ -112,87 +117,47 @@ export class DaemonClient {
}
}
private async ensureDaemon(timeoutMs?: number): Promise<void> {
const statusTimeoutMs = resolveDaemonStatusTimeout(timeoutMs);
const metadata = await readDaemonMetadata(this.metadataPath);
const configState = await this.checkConfigState(metadata);
private async ensureDaemon(): Promise<void> {
const configState = await this.checkConfigState();
if (configState === 'stale') {
await this.restartDaemon({ reason: 'stale-config', expectedPid: metadata?.pid });
await this.stop().catch(() => {});
await this.restartDaemon();
return;
}
if (configState === 'fresh') {
if (await this.isResponsive(statusTimeoutMs)) {
return;
}
return;
}
await this.startDaemon({ preflightTimeoutMs: statusTimeoutMs });
await this.startDaemon();
await this.waitForReady();
}
private async restartDaemon(options: { reason?: 'stale-config'; expectedPid?: number } = {}): Promise<void> {
await this.startingWithLock(async () => {
const currentStatus = await this.readVerifiedStatus();
if (
currentStatus &&
options.expectedPid !== undefined &&
currentStatus.pid !== options.expectedPid &&
(await this.checkConfigState()) === 'fresh'
) {
return;
}
if (options.reason === 'stale-config' && currentStatus && (await this.checkConfigState()) === 'fresh') {
return;
}
await this.stop().catch(() => {});
await this.waitForStopped();
await this.launchDaemonAndWait();
});
private async restartDaemon(): Promise<void> {
await this.startDaemon();
await this.waitForReady();
}
private async startDaemon(options: { preflightTimeoutMs?: number } = {}): Promise<void> {
await this.startingWithLock(async () => {
if (await this.isResponsive(options.preflightTimeoutMs)) {
return;
}
await this.launchDaemonAndWait();
});
}
private async startingWithLock(task: () => Promise<void>): Promise<void> {
private async startDaemon(): Promise<void> {
if (this.startingPromise) {
await this.startingPromise;
return;
}
this.startingPromise = withFileLock(this.metadataPath, async () => {
await task();
}).finally(() => {
this.startingPromise = null;
});
this.startingPromise = Promise.resolve()
.then(async () => {
const { launchDaemonDetached } = await import('./launch.js');
launchDaemonDetached({
configPath: this.options.configPath,
configExplicit: this.options.configExplicit,
rootDir: this.options.rootDir,
metadataPath: this.metadataPath,
socketPath: this.socketPath,
});
})
.finally(() => {
this.startingPromise = null;
});
await this.startingPromise;
}
private async launchDaemonAndWait(): Promise<void> {
const { launchDaemonDetached } = await import('./launch.js');
launchDaemonDetached({
configPath: this.options.configPath,
configExplicit: this.options.configExplicit,
rootDir: this.options.rootDir,
metadataPath: this.metadataPath,
socketPath: this.socketPath,
});
await this.waitForReady();
}
private async waitForStopped(): Promise<void> {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
if (!(await this.isResponsive())) {
return;
}
await delay(100);
}
throw new Error('Daemon did not stop before restart could begin.');
}
private async waitForReady(): Promise<void> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
@ -204,31 +169,20 @@ export class DaemonClient {
throw new Error('Timeout while waiting for MCPorter daemon to start.');
}
private async isResponsive(timeoutMs?: number): Promise<boolean> {
return (await this.readVerifiedStatus(timeoutMs)) !== null;
}
private async readVerifiedStatus(timeoutMs?: number): Promise<StatusResult | null> {
const metadata = await readDaemonMetadata(this.metadataPath);
if (!metadata || metadata.socketPath !== this.socketPath || !isProcessRunning(metadata.pid)) {
return null;
}
private async isResponsive(): Promise<boolean> {
try {
const status = (await this.sendRequest<StatusResult>('status', {}, timeoutMs)) as StatusResult;
if (status.pid !== metadata.pid || status.socketPath !== metadata.socketPath) {
return null;
}
return status;
await this.sendRequest('status', {});
return true;
} catch (error) {
if (isTransportError(error)) {
return null;
return false;
}
throw error;
}
}
private async checkConfigState(metadata?: DaemonMetadata | null): Promise<DaemonConfigState> {
metadata ??= await readDaemonMetadata(this.metadataPath);
private async checkConfigState(): Promise<DaemonConfigState> {
const metadata = await readDaemonMetadata(this.metadataPath);
if (!metadata) {
return 'missing';
}
@ -336,18 +290,6 @@ function isTransportError(error: unknown): boolean {
return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ETIMEDOUT' || code === 'ECONNRESET';
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
function resolveDaemonTimeout(override?: number): number {
if (typeof override === 'number' && Number.isFinite(override) && override > 0) {
return override;
@ -363,13 +305,6 @@ function resolveDaemonTimeout(override?: number): number {
return parsed;
}
function resolveDaemonStatusTimeout(override?: number): number | undefined {
if (typeof override !== 'number' || !Number.isFinite(override) || override <= 0) {
return undefined;
}
return Math.max(override, MIN_DAEMON_STATUS_TIMEOUT_MS);
}
async function statConfigMtime(configPath: string): Promise<number | null> {
try {
const stats = await fs.stat(configPath);

View File

@ -1,5 +1,4 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { LoadConfigOptions } from '../config.js';
import { listConfigLayerPaths } from '../config.js';
@ -20,8 +19,5 @@ export async function collectConfigLayers(
for (const layerPath of layerPaths) {
entries.push({ path: layerPath, mtimeMs: await statConfigMtime(layerPath) });
}
if (entries.length === 0 && options.configPath) {
entries.push({ path: path.resolve(options.configPath), mtimeMs: await statConfigMtime(options.configPath) });
}
return entries;
}

View File

@ -1,40 +0,0 @@
import { createHash } from 'node:crypto';
import type { ServerDefinition } from '../config.js';
export function hashDaemonDefinitions(definitions: readonly ServerDefinition[]): string {
const sorted = definitions.toSorted((a, b) => a.name.localeCompare(b.name));
return createHash('sha256').update(stableJsonStringify(sorted)).digest('hex').slice(0, 16);
}
function stableJsonStringify(value: unknown): string {
const json = JSON.stringify(sortJsonValue(value));
if (json === undefined) {
throw new TypeError('Cannot serialize unsupported JSON root value.');
}
return json;
}
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => sortJsonValue(entry));
}
if (!isPlainObject(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const key of Object.keys(value).toSorted()) {
const entry = (value as Record<string, unknown>)[key];
if (entry !== undefined) {
result[key] = sortJsonValue(entry);
}
}
return result;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}

View File

@ -1,13 +1,11 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { loadDaemonConfig, type ServerDefinition } from '../config.js';
import { readJsonFile, withFileLock, writeJsonFile } from '../fs-json.js';
import { writeJsonFile } from '../fs-json.js';
import { isKeepAliveServer } from '../lifecycle.js';
import { createRuntime, type Runtime } from '../runtime.js';
import { collectConfigLayers, statConfigMtime } from './config-layers.js';
import { hashDaemonDefinitions } from './definition-hash.js';
import {
createLogContext,
disposeLogContext,
@ -61,7 +59,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
rootDir: options.rootDir,
});
const keepAliveDefinitions = runtime.getDefinitions().filter(isKeepAliveServer);
const definitionHash = hashDaemonDefinitions(keepAliveDefinitions);
if (keepAliveDefinitions.length === 0) {
throw new Error('No MCP servers require keep-alive; daemon will not start.');
}
@ -86,6 +83,7 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
logPath: options.logPath,
});
await prepareSocket(options.socketPath);
await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
const configMtimeMs = await statConfigMtime(options.configPath);
@ -166,7 +164,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
startedAt,
logPath: options.logPath ?? null,
configMtimeMs,
definitionHash,
},
logContext,
shutdown,
@ -191,252 +188,29 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
});
});
let claimed = false;
await withFileLock(`${options.metadataPath}.bind`, async () => {
const live = await probeLiveDaemon(options.socketPath);
if (live) {
if (daemonConfigMatches(live, configLayers, options.configPath, configMtimeMs, definitionHash)) {
if (!(await metadataMatches(options.metadataPath, live))) {
await writeJsonFile(options.metadataPath, metadataFromStatus(live, configLayers));
}
return;
}
await stopLiveDaemon(options.socketPath, live.pid);
}
await prepareSocket(options.socketPath);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options.socketPath, () => {
server.off('error', reject);
resolve();
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options.socketPath, () => {
server.off('error', reject);
resolve();
});
await writeJsonFile(options.metadataPath, {
pid: process.pid,
socketPath: options.socketPath,
configPath: options.configPath,
configLayers,
startedAt: Date.now(),
logPath: options.logPath ?? null,
configMtimeMs,
definitionHash,
});
claimed = true;
});
if (!claimed) {
logEvent(logContext, 'Daemon already running for this config; exiting without rebinding.');
server.close();
await runtime.close().catch(() => {});
await disposeLogContext(logContext).catch(() => {});
process.exit(0);
}
await writeJsonFile(options.metadataPath, {
pid: process.pid,
socketPath: options.socketPath,
configPath: options.configPath,
configLayers,
startedAt: Date.now(),
logPath: options.logPath ?? null,
configMtimeMs,
});
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
process.once('SIGQUIT', shutdown);
}
const DAEMON_PROBE_TIMEOUT_MS = 2_000;
export async function isDaemonResponding(socketPath: string): Promise<boolean> {
return (await probeLiveDaemon(socketPath)) !== null;
}
async function probeLiveDaemon(socketPath: string): Promise<StatusResult | null> {
const status = await probeDaemonStatus(socketPath);
if (!status || status.socketPath !== socketPath || !isProcessAlive(status.pid)) {
return null;
}
return status;
}
export async function metadataMatches(
metadataPath: string,
live: Pick<StatusResult, 'pid' | 'socketPath'>
): Promise<boolean> {
try {
const existing = await readJsonFile<{ pid?: number; socketPath?: string }>(metadataPath);
return existing?.pid === live.pid && existing?.socketPath === live.socketPath;
} catch {
return false;
}
}
function metadataFromStatus(
status: StatusResult,
fallbackConfigLayers: Array<{ path: string; mtimeMs: number | null }>
): {
pid: number;
socketPath: string;
configPath: string;
configLayers?: StatusResult['configLayers'];
startedAt: number;
logPath: string | null;
configMtimeMs: number | null;
definitionHash?: string;
} {
return {
pid: status.pid,
socketPath: status.socketPath,
configPath: status.configPath,
configLayers: status.configLayers && status.configLayers.length > 0 ? status.configLayers : fallbackConfigLayers,
startedAt: status.startedAt,
logPath: status.logPath ?? null,
configMtimeMs: status.configMtimeMs ?? null,
definitionHash: status.definitionHash,
};
}
function daemonConfigMatches(
live: StatusResult,
currentLayers: Array<{ path: string; mtimeMs: number | null }>,
currentConfigPath: string,
currentConfigMtimeMs: number | null,
currentDefinitionHash: string
): boolean {
if (live.definitionHash !== currentDefinitionHash) {
return false;
}
const liveLayers = normalizeLayers(
live.configLayers && live.configLayers.length > 0
? live.configLayers
: [{ path: live.configPath, mtimeMs: live.configMtimeMs ?? null }]
);
const expectedLayers = normalizeLayers(
currentLayers.length > 0 ? currentLayers : [{ path: currentConfigPath, mtimeMs: currentConfigMtimeMs }]
);
if (liveLayers.length !== expectedLayers.length) {
return false;
}
return liveLayers.every((entry, index) => {
const expected = expectedLayers[index];
return Boolean(expected && entry.path === expected.path && entry.mtimeMs === expected.mtimeMs);
});
}
function normalizeLayers(
layers: Array<{ path: string; mtimeMs: number | null }>
): Array<{ path: string; mtimeMs: number | null }> {
const normalized = layers.map((entry) => ({
path: path.isAbsolute(entry.path) ? entry.path : path.resolve(entry.path),
mtimeMs: entry.mtimeMs ?? null,
}));
if (normalized.length < 2) {
return normalized;
}
return normalized.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}
async function stopLiveDaemon(socketPath: string, livePid: number): Promise<void> {
const stopped = await sendDaemonStop(socketPath);
if (!stopped) {
throw new Error('Live daemon did not accept stop before rebinding.');
}
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
if (!isProcessAlive(livePid)) {
return;
}
await delay(100);
}
throw new Error('Live daemon did not stop before rebinding.');
}
async function sendDaemonStop(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const request: DaemonRequest<'stop', Record<string, never>> = {
id: randomUUID(),
method: 'stop',
params: {},
};
const socket = net.createConnection(socketPath);
let buffer = '';
let settled = false;
const finish = (result: boolean): void => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.setTimeout(DAEMON_PROBE_TIMEOUT_MS, () => finish(false));
socket.once('connect', () => {
socket.write(JSON.stringify(request));
});
socket.on('data', (chunk) => {
buffer += chunk.toString();
});
socket.once('end', () => {
try {
const response = JSON.parse(buffer.trim()) as DaemonResponse<boolean>;
finish(response.ok);
} catch {
finish(false);
}
});
socket.once('error', () => finish(false));
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
async function probeDaemonStatus(socketPath: string): Promise<StatusResult | null> {
return await new Promise<StatusResult | null>((resolve) => {
const probe = net.createConnection(socketPath);
let buffer = '';
let settled = false;
const finish = (status: StatusResult | null): void => {
if (settled) {
return;
}
settled = true;
probe.removeAllListeners();
probe.destroy();
resolve(status);
};
const parse = (): StatusResult | null => {
try {
const response = JSON.parse(buffer.trim()) as DaemonResponse<StatusResult>;
return response.ok && response.result ? response.result : null;
} catch {
return null;
}
};
probe.setTimeout(DAEMON_PROBE_TIMEOUT_MS, () => finish(null));
probe.once('connect', () => {
probe.write(JSON.stringify({ id: randomUUID(), method: 'status', params: {} } satisfies DaemonRequest));
});
probe.on('data', (chunk) => {
buffer += chunk.toString();
const status = parse();
if (status) {
finish(status);
}
});
probe.once('end', () => finish(parse()));
probe.once('error', () => finish(null));
});
}
async function prepareSocket(socketPath: string): Promise<void> {
if (process.platform === 'win32') {
return;
@ -452,24 +226,18 @@ 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') {
await fs.unlink(paths.socketPath).catch(() => {});
try {
await fs.unlink(options.socketPath);
} catch {
// ignore
}
}
try {
await fs.unlink(options.metadataPath);
} catch {
// ignore
}
await fs.unlink(paths.metadataPath).catch(() => {});
}
async function handleSocketRequest(
@ -485,7 +253,6 @@ async function handleSocketRequest(
socketPath: string;
startedAt: number;
logPath: string | null;
definitionHash?: string;
},
logContext: LogContext,
shutdown: () => Promise<void>,
@ -509,13 +276,6 @@ async function handleSocketRequest(
});
}
function normalizeDaemonDisableOAuth(value: boolean | undefined): boolean {
// Daemon messages are independent requests. Omission means the caller did
// not request OAuth suppression, so a previous --no-oauth pooled transport
// must not make later ordinary calls inherit the no-OAuth posture.
return value === true;
}
async function processRequest(
rawPayload: string,
runtime: Runtime,
@ -528,7 +288,6 @@ async function processRequest(
socketPath: string;
startedAt: number;
logPath: string | null;
definitionHash?: string;
},
logContext: LogContext,
preParsedRequest?: DaemonRequest
@ -567,7 +326,6 @@ async function processRequest(
const result = await runtime.callTool(params.server, params.tool, {
args: params.args ?? {},
timeoutMs: params.timeoutMs,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
@ -585,7 +343,6 @@ async function processRequest(
case 'listTools': {
const params = request.params as ListToolsParams;
ensureManaged(params.server, managedServers);
const definition = managedServers.get(params.server)!;
const loggable = shouldLogServer(logContext, params.server);
if (loggable) {
logEvent(logContext, `listTools start server=${params.server}`);
@ -593,9 +350,8 @@ async function processRequest(
try {
const result = await runtime.listTools(params.server, {
includeSchema: params.includeSchema,
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
autoAuthorize: params.autoAuthorize,
allowCachedAuth: params.allowCachedAuth ?? true,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
@ -618,11 +374,7 @@ async function processRequest(
logEvent(logContext, `listResources start server=${params.server}`);
}
try {
const result = await runtime.listResources(params.server, {
...params.params,
allowCachedAuth: params.allowCachedAuth,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
const result = await runtime.listResources(params.server, params.params);
markActivity(params.server, activity);
if (loggable) {
logEvent(logContext, `listResources success server=${params.server}`);
@ -644,10 +396,7 @@ async function processRequest(
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
}
try {
const result = await runtime.readResource(params.server, params.uri, {
allowCachedAuth: params.allowCachedAuth,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
const result = await runtime.readResource(params.server, params.uri);
markActivity(params.server, activity);
if (loggable) {
logEvent(logContext, `readResource success server=${params.server}`);
@ -693,7 +442,6 @@ async function processRequest(
configPath: metadata.configPath,
configLayers: metadata.configLayers,
configMtimeMs: metadata.configMtimeMs,
definitionHash: metadata.definitionHash,
socketPath: metadata.socketPath,
logPath: metadata.logPath ?? undefined,
servers: Array.from(managedServers.values()).map((def) => {
@ -728,16 +476,6 @@ async function processRequest(
}
}
function resolveDaemonListToolsAutoAuthorize(
params: ListToolsParams,
definition: ServerDefinition
): boolean | undefined {
if (params.autoAuthorize === false && definition.command.kind === 'stdio') {
return undefined;
}
return params.autoAuthorize;
}
export async function __testProcessRequest(
rawPayload: string,
runtime: Runtime,
@ -750,7 +488,6 @@ export async function __testProcessRequest(
socketPath: string;
startedAt: number;
logPath: string | null;
definitionHash?: string;
},
logContext: LogContext,
preParsedRequest?: DaemonRequest

View File

@ -28,7 +28,6 @@ export interface CallToolParams {
readonly tool: string;
readonly args?: Record<string, unknown>;
readonly timeoutMs?: number;
readonly disableOAuth?: boolean;
}
export interface ListToolsParams {
@ -36,21 +35,16 @@ export interface ListToolsParams {
readonly includeSchema?: boolean;
readonly autoAuthorize?: boolean;
readonly allowCachedAuth?: boolean;
readonly disableOAuth?: boolean;
}
export interface ListResourcesParams {
readonly server: string;
readonly params?: Record<string, unknown>;
readonly allowCachedAuth?: boolean;
readonly disableOAuth?: boolean;
}
export interface ReadResourceParams {
readonly server: string;
readonly uri: string;
readonly allowCachedAuth?: boolean;
readonly disableOAuth?: boolean;
}
export interface CloseServerParams {
@ -66,7 +60,6 @@ export interface StatusResult {
readonly path: string;
readonly mtimeMs: number | null;
}>;
readonly definitionHash?: string;
readonly socketPath: string;
readonly logPath?: string;
readonly servers: Array<{

View File

@ -1,14 +1,8 @@
import type { ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import type { ServerDefinition } from '../config.js';
import { isKeepAliveServer } from '../lifecycle.js';
import type {
CallOptions,
ConnectOptions,
ListResourcesOptions,
ListToolsOptions,
ReadResourceOptions,
Runtime,
} from '../runtime.js';
import type { CallOptions, ListToolsOptions, Runtime } from '../runtime.js';
import type { DaemonClient } from './client.js';
interface KeepAliveRuntimeOptions {
@ -68,7 +62,6 @@ class KeepAliveRuntime implements Runtime {
includeSchema: options?.includeSchema,
autoAuthorize: options?.autoAuthorize,
allowCachedAuth: options?.allowCachedAuth ?? true,
disableOAuth: options?.disableOAuth,
})
)) as Awaited<ReturnType<Runtime['listTools']>>;
}
@ -83,45 +76,30 @@ class KeepAliveRuntime implements Runtime {
tool: toolName,
args: options?.args,
timeoutMs: options?.timeoutMs,
disableOAuth: options?.disableOAuth,
})
);
}
return this.base.callTool(server, toolName, options);
}
async listResources(server: string, options?: ListResourcesOptions): Promise<unknown> {
if (options?.oauthSessionOptions) {
return this.base.listResources(server, options);
}
const { allowCachedAuth, disableOAuth, ...params } = options ?? {};
async listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown> {
if (this.shouldUseDaemon(server)) {
return this.invokeWithRestart(server, 'listResources', () =>
this.daemon.listResources({ server, params, allowCachedAuth, disableOAuth })
this.daemon.listResources({ server, params: options ?? {} })
);
}
return this.base.listResources(server, options);
}
async readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown> {
if (options?.oauthSessionOptions) {
return this.base.readResource(server, uri, options);
}
async readResource(server: string, uri: string): Promise<unknown> {
if (this.shouldUseDaemon(server)) {
return this.invokeWithRestart(server, 'readResource', () =>
this.daemon.readResource({
server,
uri,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
})
);
return this.invokeWithRestart(server, 'readResource', () => this.daemon.readResource({ server, uri }));
}
return this.base.readResource(server, uri, options);
return this.base.readResource(server, uri);
}
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
return this.base.connect(server, options);
async connect(server: string): Promise<Awaited<ReturnType<Runtime['connect']>>> {
return this.base.connect(server);
}
async close(server?: string): Promise<void> {

View File

@ -8,7 +8,6 @@ const LOCK_POLL_MS = 25;
const MALFORMED_LOCK_STALE_MS = 1_000;
const MAX_SYMLINK_DEPTH = 40;
const DEFAULT_ATOMIC_FILE_MODE = 0o600;
const localLockTails = new Map<string, Promise<void>>();
// readJsonFile reads a JSON file and returns undefined when the file does not exist.
export async function readJsonFile<T = unknown>(filePath: string): Promise<T | undefined> {
@ -65,51 +64,49 @@ export async function withFileLock<T>(
options: { timeoutMs?: number } = {}
): Promise<T> {
const lockTargetPath = await resolvePathFollowingSymlinks(filePath);
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
let lockPath = `${lockTargetPath}.lock`;
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
const startedAt = Date.now();
return withLocalLock(lockTargetPath, timeoutMs, async () => {
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
let lockPath = `${lockTargetPath}.lock`;
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
let acquired = false;
while (!acquired) {
try {
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
encoding: 'utf8',
flag: 'wx',
});
acquired = true;
break;
} catch (error) {
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
lockPath = fallbackLockPath;
continue;
}
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
if (await removeRecoverableLock(lockPath)) {
continue;
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
}
await sleep(LOCK_POLL_MS);
}
}
let acquired = false;
while (!acquired) {
try {
return await task();
} finally {
await fs.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
encoding: 'utf8',
flag: 'wx',
});
acquired = true;
break;
} catch (error) {
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
lockPath = fallbackLockPath;
continue;
}
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
if (await removeRecoverableLock(lockPath)) {
continue;
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
}
await sleep(LOCK_POLL_MS);
}
});
}
try {
return await task();
} finally {
await fs.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
});
}
}
function isPermissionError(error: unknown): boolean {
@ -121,46 +118,6 @@ async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function withLocalLock<T>(key: string, timeoutMs: number, task: () => Promise<T>): Promise<T> {
const previous = localLockTails.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => current);
localLockTails.set(key, tail);
try {
await waitForLocalLock(previous, timeoutMs, key);
return await task();
} finally {
release();
void tail.then(() => {
if (localLockTails.get(key) === tail) {
localLockTails.delete(key);
}
});
}
}
async function waitForLocalLock(previous: Promise<void>, timeoutMs: number, key: string): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
previous,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`Timed out waiting for file lock ${key}.lock`)),
Math.max(0, timeoutMs)
);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function resolveAtomicWriteTarget(filePath: string): Promise<{ path: string; mode?: number }> {
try {
const stats = await fs.lstat(filePath);

View File

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

View File

@ -2,17 +2,7 @@ export type { CommandSpec, ServerDefinition } from './config.js';
export { loadServerDefinitions } from './config.js';
export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.js';
export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js';
export type {
CallOptions,
ConnectOptions,
ListResourcesOptions,
ListToolsOptions,
ReadResourceOptions,
Runtime,
RuntimeLogger,
RuntimeOptions,
ServerToolInfo,
} from './runtime.js';
export type { CallOptions, ListToolsOptions, Runtime, RuntimeLogger, ServerToolInfo } from './runtime.js';
export { callOnce, createRuntime } from './runtime.js';
export type { GeneratedRuntimeContext } from './generated-daemon-runtime.js';
export { createGeneratedKeepAliveRuntime } from './generated-daemon-runtime.js';

View File

@ -1,6 +1,6 @@
import type { CommandSpec, RawLifecycle, ServerDefinition, ServerLifecycle } from './config-schema.js';
const DEFAULT_KEEP_ALIVE = new Set(['chrome-devtools', 'mobile-mcp', 'playwright', 'cloudbase']);
const DEFAULT_KEEP_ALIVE = new Set(['chrome-devtools', 'mobile-mcp', 'playwright']);
const includeOverride = parseList(process.env.MCPORTER_KEEPALIVE);
const excludeOverride = parseList(process.env.MCPORTER_DISABLE_KEEPALIVE ?? process.env.MCPORTER_NO_KEEPALIVE);
@ -19,7 +19,6 @@ const KEEP_ALIVE_COMMANDS: CommandSignature[] = [
{ label: 'chrome-devtools', fragments: ['chrome-devtools-mcp'] },
{ label: 'mobile-mcp', fragments: ['@mobilenext/mobile-mcp', 'mobile-mcp'] },
{ label: 'playwright', fragments: ['@playwright/mcp', 'playwright/mcp'] },
{ label: 'cloudbase', fragments: ['@cloudbase/cloudbase-mcp', 'cloudbase-mcp'] },
];
const CHROME_DEVTOOLS_URL_PLACEHOLDERS = [String.raw`\${CHROME_DEVTOOLS_URL}`, '$env:CHROME_DEVTOOLS_URL'];

View File

@ -62,16 +62,6 @@ function tokenExpirySeconds(tokens: OAuthTokens): number | undefined {
return undefined;
}
function cachedTokensChanged(original: OAuthTokens, current: OAuthTokens | undefined): boolean {
if (!current || typeof current.access_token !== 'string' || current.access_token.trim().length === 0) {
return false;
}
if (typeof original.refresh_token === 'string' && typeof current.refresh_token === 'string') {
return current.refresh_token !== original.refresh_token || current.access_token !== original.access_token;
}
return current.access_token !== original.access_token;
}
function shouldRefreshCachedToken(tokens: OAuthTokens, skewSeconds = TOKEN_EXPIRY_SKEW_SECONDS): boolean {
const expiresAt = tokenExpirySeconds(tokens);
if (expiresAt !== undefined) {
@ -96,37 +86,6 @@ function resourceForRefresh(
return new URL(resourceMetadata.resource);
}
function unrecoverableOAuthRefreshCode(error: unknown): string | undefined {
const errorCode = oauthErrorCode(error);
if (errorCode && ['invalid_client', 'invalid_grant', 'unauthorized_client'].includes(errorCode)) {
return errorCode;
}
return undefined;
}
function oauthErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
const { errorCode, name } = error as { errorCode?: unknown; name?: unknown };
if (typeof errorCode === 'string' && errorCode.length > 0) {
return errorCode.toLowerCase();
}
if (typeof name === 'string') {
const normalized = name.toLowerCase();
if (normalized === 'invalidclienterror') {
return 'invalid_client';
}
if (normalized === 'invalidgranterror') {
return 'invalid_grant';
}
if (normalized === 'unauthorizedclienterror') {
return 'unauthorized_client';
}
}
return undefined;
}
class DirectoryPersistence implements OAuthPersistence {
private readonly tokenPath: string;
private readonly clientInfoPath: string;
@ -152,7 +111,7 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readTokens(): Promise<OAuthTokens | undefined> {
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
return readJsonFile<OAuthTokens>(this.tokenPath);
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
@ -162,7 +121,7 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
@ -187,31 +146,9 @@ 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);
@ -407,7 +344,7 @@ export async function clearOAuthCaches(
await legacy.clear(scope);
}
if (definition.tokenCacheDir && scope === 'all') {
if (definition.tokenCacheDir) {
await fs.rm(definition.tokenCacheDir, { recursive: true, force: true });
}
@ -476,22 +413,6 @@ export async function readCachedAccessToken(
error instanceof Error ? error.message : String(error)
}`
);
const unrecoverableCode = unrecoverableOAuthRefreshCode(error);
if (unrecoverableCode) {
const latestTokens = await persistence.readTokens();
if (cachedTokensChanged(tokens, latestTokens)) {
logger?.debug?.(`Kept cached OAuth token for '${definition.name}' because another refresh updated it first.`);
return latestTokens?.access_token;
}
const scope = unrecoverableCode === 'invalid_grant' ? 'tokens' : 'all';
await clearOAuthCaches(definition, logger, scope);
logger?.debug?.(
`Cleared cached OAuth ${scope === 'all' ? 'credentials' : 'token'} for '${
definition.name
}' after unrecoverable refresh failure.`
);
return undefined;
}
return tokens.access_token;
}
}

View File

@ -27,12 +27,6 @@ interface VaultReadState {
needsRepair: boolean;
}
interface SameUrlCredentials {
tokens?: OAuthTokens;
clientInfo?: OAuthClientInformationMixed;
sourceKeys: VaultKey[];
}
export function getOAuthVaultPath(): string {
return path.join(mcporterDir('data'), 'credentials.json');
}
@ -82,110 +76,14 @@ export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const exact = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
const fallback = findSameUrlCredentials(vault, definition, key, exact);
if (!fallback.tokens && !fallback.clientInfo) {
return exact;
}
if (!exact) {
return {
serverName: definition.name,
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
updatedAt: new Date().toISOString(),
tokens: fallback.tokens,
clientInfo: fallback.clientInfo,
};
}
return {
...exact,
tokens: exact.tokens ?? fallback.tokens,
clientInfo: exact.clientInfo ?? (exact.tokens ? undefined : fallback.clientInfo),
};
}
function findSameUrlCredentials(
vault: VaultFile,
definition: ServerDefinition,
exactKey: VaultKey,
exact: VaultEntry | undefined
): SameUrlCredentials {
if (definition.command.kind !== 'http') {
return { sourceKeys: [] };
}
const serverUrl = definition.command.url.toString();
const candidates = Object.entries(vault.entries)
.filter(
([key, entry]) =>
key !== exactKey &&
isVaultEntry(entry) &&
entry.serverUrl === serverUrl &&
isLegacyOAuthRenameCandidate(definition, entry) &&
(entry.tokens || entry.clientInfo)
)
.map(([key, entry]) => ({ key, entry }))
.toSorted((a, b) => Date.parse(b.entry.updatedAt) - Date.parse(a.entry.updatedAt));
const requiredClientId = definition.oauthClientId ?? clientIdFromEntry(exact);
if (requiredClientId) {
const tokenSource = candidates.find(
({ entry }) => (entry.tokens || entry.clientInfo) && clientIdFromEntry(entry) === requiredClientId
);
return {
tokens: tokenSource?.entry.tokens,
clientInfo: exact?.clientInfo ? undefined : tokenSource?.entry.clientInfo,
sourceKeys: tokenSource ? [tokenSource.key] : [],
};
}
const source = candidates.find(({ entry }) => entry.clientInfo && clientIdFromEntry(entry));
return {
tokens: source?.entry.tokens,
clientInfo: source?.entry.clientInfo,
sourceKeys: source ? [source.key] : [],
};
}
function isLegacyOAuthRenameCandidate(definition: ServerDefinition, entry: VaultEntry): boolean {
return entry.serverName === `${definition.name}-oauth`;
}
function legacyOAuthRenameKeys(vault: VaultFile, definition: ServerDefinition, exactKey: VaultKey): VaultKey[] {
if (definition.command.kind !== 'http') {
return [];
}
const serverUrl = definition.command.url.toString();
return Object.entries(vault.entries)
.filter(
([key, entry]) =>
key !== exactKey &&
isVaultEntry(entry) &&
entry.serverUrl === serverUrl &&
isLegacyOAuthRenameCandidate(definition, entry)
)
.map(([key]) => key);
}
function isVaultEntry(entry: unknown): entry is VaultEntry {
return Boolean(
entry &&
typeof entry === 'object' &&
typeof (entry as VaultEntry).serverName === 'string' &&
typeof (entry as VaultEntry).updatedAt === 'string'
);
}
function clientIdFromEntry(entry: VaultEntry | undefined): string | undefined {
const clientId = entry?.clientInfo?.client_id;
return typeof clientId === 'string' && clientId.length > 0 ? clientId : undefined;
return vault.entries[vaultKeyForDefinition(definition)];
}
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
await withFileLock(getOAuthVaultPath(), async () => {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
const fallback = findSameUrlCredentials(vault, definition, key, existing);
const current = existing ?? {
const current = vault.entries[key] ?? {
serverName: definition.name,
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
updatedAt: new Date().toISOString(),
@ -193,8 +91,6 @@ export async function saveVaultEntry(definition: ServerDefinition, patch: Partia
vault.entries[key] = {
...current,
...patch,
clientInfo:
patch.clientInfo ?? current.clientInfo ?? (patch.tokens && !current.tokens ? fallback.clientInfo : undefined),
updatedAt: new Date().toISOString(),
};
await writeVault(vault);
@ -208,10 +104,8 @@ export async function clearVaultEntry(
const key = vaultKeyForDefinition(definition);
await withFileLock(getOAuthVaultPath(), async () => {
const { vault, needsRepair } = await readVaultState();
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
const fallback = findSameUrlCredentials(vault, definition, key, existing);
const inheritedKeys = scope === 'all' ? legacyOAuthRenameKeys(vault, definition, key) : fallback.sourceKeys;
if (!existing && inheritedKeys.length === 0) {
const existing = vault.entries[key];
if (!existing) {
if (needsRepair) {
await writeVault(vault);
}
@ -219,7 +113,7 @@ export async function clearVaultEntry(
}
if (scope === 'all') {
delete vault.entries[key];
} else if (existing) {
} else {
const updated: VaultEntry = { ...existing };
if (scope === 'tokens') {
delete updated.tokens;
@ -236,25 +130,6 @@ export async function clearVaultEntry(
updated.updatedAt = new Date().toISOString();
vault.entries[key] = updated;
}
for (const fallbackKey of inheritedKeys) {
const inherited = vault.entries[fallbackKey];
if (!inherited) {
continue;
}
if (scope === 'all') {
delete vault.entries[fallbackKey];
continue;
}
const updated: VaultEntry = { ...inherited };
if (scope === 'tokens') {
delete updated.tokens;
}
if (scope === 'client') {
delete updated.clientInfo;
}
updated.updatedAt = new Date().toISOString();
vault.entries[fallbackKey] = updated;
}
await writeVault(vault);
});
}

View File

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

View File

@ -4,40 +4,26 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Logger } from './logging.js';
export interface CloseTransportAndWaitOptions {
readonly throwOnCloseError?: boolean;
}
// closeTransportAndWait closes transports and ensures backing processes exit cleanly.
export async function closeTransportAndWait(
logger: Logger,
transport: Transport & { close(): Promise<void> },
options: CloseTransportAndWaitOptions = {}
transport: Transport & { close(): Promise<void> }
): Promise<void> {
const pidBeforeClose = getTransportPid(transport);
const childProcess =
transport instanceof StdioClientTransport
? ((transport as unknown as { _process?: ChildProcess | null })._process ?? null)
: null;
let closeError: unknown;
try {
await transport.close();
} catch (error) {
if (options.throwOnCloseError) {
closeError = error;
} else {
logger.warn(`Failed to close transport cleanly: ${(error as Error).message}`);
}
logger.warn(`Failed to close transport cleanly: ${(error as Error).message}`);
}
if (childProcess) {
await waitForChildClose(childProcess, 1_000).catch(() => {});
}
if (closeError) {
throw closeError;
}
if (!pidBeforeClose) {
return;
}

View File

@ -6,8 +6,6 @@ import { closeTransportAndWait } from './runtime-process-utils.js';
import './sdk-patches.js';
import { shouldResetConnection } from './runtime/errors.js';
import { resolveOAuthTimeoutFromEnv } from './runtime/oauth.js';
import { resolveRecordingPath } from './runtime/record-transport.js';
import { ReplayTransport } from './runtime/replay-transport.js';
import { type ClientContext, createClientContext } from './runtime/transport.js';
import { normalizeTimeout, raceWithTimeout } from './runtime/utils.js';
import { filterTools, isToolAllowed, validateToolFilters } from './tool-filters.js';
@ -18,14 +16,6 @@ export { MCPORTER_VERSION } from './version.js';
const PACKAGE_NAME = 'mcporter';
const OAUTH_CODE_TIMEOUT_MS = resolveOAuthTimeoutFromEnv();
type CachedClientEntry = {
readonly server: string;
readonly promise: Promise<ClientContext>;
readonly contextPromise?: Promise<ClientContext>;
readonly allowCachedAuth: boolean | undefined;
readonly disableOAuth: boolean;
};
export interface RuntimeOptions {
readonly configPath?: string;
readonly servers?: ServerDefinition[];
@ -43,11 +33,6 @@ export type RuntimeLogger = Logger;
export interface CallOptions {
readonly args?: CallToolRequest['params']['arguments'];
readonly timeoutMs?: number;
/**
* Suppress interactive OAuth for this call while still allowing cached
* bearer tokens to be applied. Intended for headless callers.
*/
readonly disableOAuth?: boolean;
}
export interface ListToolsOptions {
@ -55,49 +40,13 @@ export interface ListToolsOptions {
readonly autoAuthorize?: boolean;
readonly allowCachedAuth?: boolean;
readonly oauthSessionOptions?: OAuthSessionOptions;
/**
* Suppress interactive OAuth for this listing while keeping the connection
* cache available. Prefer this over `autoAuthorize: false` for long-running
* headless callers that need cached-token-only behavior.
*/
readonly disableOAuth?: boolean;
}
export type ListResourcesOptions = Partial<ListResourcesRequest['params']> & {
readonly allowCachedAuth?: boolean;
readonly oauthSessionOptions?: OAuthSessionOptions;
readonly disableOAuth?: boolean;
};
export interface ReadResourceOptions {
readonly allowCachedAuth?: boolean;
readonly oauthSessionOptions?: OAuthSessionOptions;
readonly disableOAuth?: boolean;
}
export interface ConnectOptions {
interface ConnectOptions {
readonly maxOAuthAttempts?: number;
readonly skipCache?: boolean;
readonly allowCachedAuth?: boolean;
readonly oauthSessionOptions?: OAuthSessionOptions;
/**
* When `true`, never start an OAuth flow for this server equivalent
* to `maxOAuthAttempts: 0` for the purpose of avoiding interactive
* authorization. Unlike `maxOAuthAttempts: 0`, callers passing
* `disableOAuth: true` participate in connection caching: repeated
* `connect()` / `callTool()` / `listTools()` calls reuse the same
* `ClientContext`, and `close()` reaps it.
*
* Intended for long-running headless callers (daemons, scheduled jobs,
* CI workers) that have no browser and must rely on cached tokens.
*
* Cache identity: clients established with `disableOAuth: true` are
* stored in their own cache slot sharing with a connection that
* could refresh into an OAuth flow would violate the no-browser-launch
* guarantee. Switching the flag between calls keeps both variants cached
* until the caller closes the server or runtime.
*/
readonly disableOAuth?: boolean;
}
export interface Runtime {
@ -108,9 +57,9 @@ export interface Runtime {
getInstructions?(server: string): Promise<string | undefined>;
listTools(server: string, options?: ListToolsOptions): Promise<ServerToolInfo[]>;
callTool(server: string, toolName: string, options?: CallOptions): Promise<unknown>;
listResources(server: string, options?: ListResourcesOptions): Promise<unknown>;
readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown>;
connect(server: string, options?: ConnectOptions): Promise<ClientContext>;
listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown>;
readResource(server: string, uri: string): Promise<unknown>;
connect(server: string): Promise<ClientContext>;
close(server?: string): Promise<void>;
}
@ -141,13 +90,11 @@ export async function callOnce(params: {
toolName: string;
args?: Record<string, unknown>;
configPath?: string;
disableOAuth?: boolean;
}): Promise<unknown> {
const runtime = await createRuntime({ configPath: params.configPath });
try {
return await runtime.callTool(params.server, params.toolName, {
args: params.args,
disableOAuth: params.disableOAuth,
});
} finally {
await runtime.close(params.server);
@ -156,18 +103,16 @@ export async function callOnce(params: {
class McpRuntime implements Runtime {
private readonly definitions: Map<string, ServerDefinition>;
private readonly clients = new Map<string, CachedClientEntry>();
private readonly activeClientKeys = new Map<string, string>();
private readonly contextCacheKeys = new WeakMap<ClientContext, string>();
private readonly contextCachePromises = new WeakMap<ClientContext, Promise<ClientContext>>();
private readonly connectionSetupTails = new Map<string, Promise<void>>();
private readonly serverGenerations = new Map<string, number>();
private readonly retirementPromises = new Map<string, Set<Promise<void>>>();
private readonly clients = new Map<
string,
{
readonly promise: Promise<ClientContext>;
readonly allowCachedAuth: boolean | undefined;
}
>();
private readonly logger: RuntimeLogger;
private readonly clientInfo: { name: string; version: string };
private readonly oauthTimeoutMs?: number;
private readonly recordPath?: string;
private readonly replayPath?: string;
constructor(servers: ServerDefinition[], options: RuntimeOptions = {}) {
for (const server of servers) {
@ -180,13 +125,6 @@ class McpRuntime implements Runtime {
version: MCPORTER_VERSION,
};
this.oauthTimeoutMs = options.oauthTimeoutMs;
const recordSession = process.env.MCPORTER_RECORD;
const replaySession = process.env.MCPORTER_REPLAY;
if (recordSession && replaySession) {
this.logger.warn('Both MCPORTER_RECORD and MCPORTER_REPLAY are set; recording mode wins.');
}
this.recordPath = recordSession ? resolveRecordingPath(recordSession) : undefined;
this.replayPath = !recordSession && replaySession ? resolveRecordingPath(replaySession) : undefined;
}
// listServers returns configured names sorted alphabetically for stable CLI output.
@ -213,15 +151,12 @@ class McpRuntime implements Runtime {
if (!options.overwrite && this.definitions.has(definition.name)) {
throw new Error(`MCP server '${definition.name}' already exists.`);
}
this.bumpServerGeneration(definition.name);
this.definitions.set(definition.name, definition);
this.retireCachedEntriesForServer(definition.name);
this.clients.delete(definition.name);
}
async getInstructions(server: string): Promise<string | undefined> {
const active = this.activeClientForServer(server);
const fallbackEntries = active ? [] : this.cachedEntriesForServer(server);
const cached = active ?? (fallbackEntries.length === 1 ? fallbackEntries[0] : undefined);
const cached = this.clients.get(server.trim());
if (!cached) {
return undefined;
}
@ -242,27 +177,15 @@ class McpRuntime implements Runtime {
// listTools queries tool metadata and optionally includes schemas when requested.
async listTools(server: string, options: ListToolsOptions = {}): Promise<ServerToolInfo[]> {
// Toggle auto authorization so list can run without forcing OAuth flows.
// `disableOAuth` is the cache-friendly suppression path; when present it
// supersedes the legacy `autoAuthorize: false` uncached behavior.
const autoAuthorize = options.autoAuthorize !== false;
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
const allowCachedAuth = this.effectiveAllowCachedAuthForOperation(
server,
options.allowCachedAuth,
disableOAuth,
true
);
const useLegacyNoAuthorize = !autoAuthorize && disableOAuth !== true;
const context = await this.connect(server, {
maxOAuthAttempts: useLegacyNoAuthorize ? 0 : undefined,
skipCache: useLegacyNoAuthorize,
allowCachedAuth,
maxOAuthAttempts: autoAuthorize ? undefined : 0,
skipCache: !autoAuthorize,
allowCachedAuth: options.allowCachedAuth ?? true,
oauthSessionOptions: options.oauthSessionOptions,
disableOAuth,
});
let closeError: unknown;
const tools: ServerToolInfo[] = [];
try {
const tools: ServerToolInfo[] = [];
let cursor: string | undefined;
do {
const response = await context.client.listTools(cursor ? { cursor } : undefined);
@ -276,25 +199,20 @@ class McpRuntime implements Runtime {
);
cursor = response.nextCursor ?? undefined;
} while (cursor);
return filterTools(tools, this.definitions.get(server.trim()));
} catch (error) {
// Keep-alive STDIO transports often die when Chrome closes; drop the cached client
// so the next call spins up a fresh process instead of reusing the broken handle.
await this.resetConnectionOnError(server, error, context);
await this.resetConnectionOnError(server, error);
throw error;
} finally {
if (useLegacyNoAuthorize) {
try {
await this.closeContext(context);
} catch (error) {
closeError = error;
}
if (!autoAuthorize) {
await context.client.close().catch(() => {});
await closeTransportAndWait(this.logger, context.transport).catch(() => {});
await context.oauthSession?.close().catch(() => {});
}
}
if (closeError !== undefined) {
throw closeError;
}
return filterTools(tools, this.definitions.get(server.trim()));
}
// callTool executes a tool using the args provided by the caller.
@ -305,14 +223,10 @@ class McpRuntime implements Runtime {
`Tool '${toolName}' is not accessible on server '${definition.name}' (blocked by configuration).`
);
}
let context: ClientContext | undefined;
try {
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
context = await this.connect(server, {
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(server, undefined, disableOAuth, true),
disableOAuth,
const { client } = await this.connect(server, {
allowCachedAuth: true,
});
const { client } = context;
const params: CallToolRequest['params'] = {
name: toolName,
arguments: options.args ?? {},
@ -333,570 +247,121 @@ class McpRuntime implements Runtime {
} catch (error) {
// Runtime timeouts and transport crashes should tear down the cached connection so
// the daemon (or direct runtime) can relaunch the MCP server on the next attempt.
await this.resetConnectionOnError(server, error, context);
await this.resetConnectionOnError(server, error);
throw error;
}
}
// listResources delegates to the MCP resources/list method with passthrough params.
async listResources(server: string, options: ListResourcesOptions = {}): Promise<unknown> {
const { allowCachedAuth, disableOAuth, oauthSessionOptions, ...params } = options;
let context: ClientContext | undefined;
async listResources(server: string, options: Partial<ListResourcesRequest['params']> = {}): Promise<unknown> {
try {
const effectiveDisableOAuth = this.effectiveDisableOAuthForOperation(server, disableOAuth);
context = await this.connect(server, {
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(
server,
allowCachedAuth,
effectiveDisableOAuth,
undefined
),
oauthSessionOptions,
disableOAuth: effectiveDisableOAuth,
});
const { client } = context;
return await client.listResources(params as ListResourcesRequest['params']);
const { client } = await this.connect(server);
return await client.listResources(options as ListResourcesRequest['params']);
} catch (error) {
// Fatal listResources errors usually mean the underlying transport has gone away.
await this.resetConnectionOnError(server, error, context);
await this.resetConnectionOnError(server, error);
throw error;
}
}
async readResource(server: string, uri: string, options: ReadResourceOptions = {}): Promise<unknown> {
let context: ClientContext | undefined;
async readResource(server: string, uri: string): Promise<unknown> {
try {
const effectiveDisableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
context = await this.connect(server, {
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(
server,
options.allowCachedAuth,
effectiveDisableOAuth,
undefined
),
oauthSessionOptions: options.oauthSessionOptions,
disableOAuth: effectiveDisableOAuth,
});
const { client } = context;
const { client } = await this.connect(server);
return await client.readResource({ uri } satisfies ReadResourceRequest['params']);
} catch (error) {
await this.resetConnectionOnError(server, error, context);
await this.resetConnectionOnError(server, error);
throw error;
}
}
private effectiveDisableOAuthForOperation(server: string, requested: boolean | undefined): boolean | undefined {
if (requested !== undefined) {
return requested;
}
const cached = this.cachedEntriesForServer(server);
const active = this.activeClientForServer(server);
if (active) {
return active.disableOAuth;
}
if (cached.length === 0) {
return undefined;
}
const [first] = cached;
return cached.every((entry) => entry.disableOAuth === first?.disableOAuth) ? first?.disableOAuth : undefined;
}
private effectiveAllowCachedAuthForOperation(
server: string,
requested: boolean | undefined,
disableOAuth: boolean | undefined,
defaultValue: boolean | undefined
): boolean | undefined {
if (requested !== undefined) {
return requested;
}
if (disableOAuth !== true) {
return defaultValue;
}
const active = this.activeClientForServer(server);
if (active?.disableOAuth === true) {
return active.allowCachedAuth;
}
const cached = this.cachedEntriesForServer(server).filter((entry) => entry.disableOAuth);
return cached.length === 1 ? cached[0]?.allowCachedAuth : defaultValue;
}
private cachedEntriesForServer(server: string): CachedClientEntry[] {
const normalized = server.trim();
return [...this.clients.values()].filter((entry) => entry.server === normalized);
}
private retireCachedEntriesForServer(server: string): void {
const normalized = server.trim();
const retired: CachedClientEntry[] = [];
for (const [key, cached] of this.clients.entries()) {
if (cached.server === normalized) {
this.clients.delete(key);
retired.push(cached);
}
}
this.activeClientKeys.delete(normalized);
if (retired.length > 0) {
const retirement = this.trackRetirement(normalized, this.closeCachedEntries(retired));
void retirement.catch((error) => {
const detail = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to close retired '${normalized}' connection: ${detail}`);
});
}
}
private activeClientForServer(server: string): CachedClientEntry | undefined {
const normalized = server.trim();
const activeKey = this.activeClientKeys.get(normalized);
if (!activeKey) {
return undefined;
}
const active = this.clients.get(activeKey);
return active?.server === normalized ? active : undefined;
}
private serverGeneration(server: string): number {
return this.serverGenerations.get(server.trim()) ?? 0;
}
private bumpServerGeneration(server: string): void {
const normalized = server.trim();
this.serverGenerations.set(normalized, this.serverGeneration(normalized) + 1);
}
private bumpAllServerGenerations(): void {
const servers = new Set<string>([
...this.definitions.keys(),
...[...this.clients.values()].map((entry) => entry.server),
...this.connectionSetupTails.keys(),
]);
for (const server of servers) {
this.bumpServerGeneration(server);
}
}
// connect lazily instantiates a client context per server and memoizes it.
async connect(server: string, options: ConnectOptions = {}): Promise<ClientContext> {
// Reuse cached connections unless the caller explicitly opted out.
const normalized = server.trim();
let definition = this.definitions.get(normalized);
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
if (useCache) {
const existing = this.clients.get(normalized);
if (existing) {
if (existing.allowCachedAuth === options.allowCachedAuth || options.allowCachedAuth === undefined) {
return existing.promise;
}
await this.close(normalized).catch(() => {});
}
}
const definition = this.definitions.get(normalized);
if (!definition) {
throw new Error(`Unknown MCP server '${normalized}'.`);
}
const generation = this.serverGeneration(normalized);
// `maxOAuthAttempts: 0` keeps its legacy escape-the-cache contract.
// `disableOAuth: true` is the cache-friendly OAuth-suppression knob:
// it disables the interactive OAuth flow at the transport layer but
// participates in caching (own slot, see the eviction rule below).
const disableOAuth = options.disableOAuth === true;
// Normalize: a caller asking for `disableOAuth: true` has no path to
// OAuth, so cached-token application is the only auth they can ever
// use — default `allowCachedAuth: true` when the caller didn't pick
// a side. Without this, the documented headless setup
// `connect(server, { disableOAuth: true })` stored
// `allowCachedAuth: undefined`, and the next internal `callTool` /
// `listTools` (which force `allowCachedAuth: true`) immediately
// evicted and reopened the transport. Explicit `false` is honored
// (header-only / anonymous callers).
const effectiveAllowCachedAuth = options.allowCachedAuth ?? (disableOAuth ? true : undefined);
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
let ignoresAuthCachePolicy = this.ignoresAuthCachePolicy(definition);
let cacheAllowCachedAuth = ignoresAuthCachePolicy ? undefined : effectiveAllowCachedAuth;
let cacheDisableOAuth = ignoresAuthCachePolicy ? false : disableOAuth;
let cacheKey = this.cacheKey(normalized, cacheAllowCachedAuth, cacheDisableOAuth);
if (useCache) {
const existing = this.findCachedEntryForRequest(
normalized,
definition,
ignoresAuthCachePolicy ? undefined : options.allowCachedAuth,
cacheAllowCachedAuth,
cacheDisableOAuth
);
if (existing) {
const [existingKey, cached] = existing;
const activeEntry = ignoresAuthCachePolicy
? {
...cached,
allowCachedAuth: effectiveAllowCachedAuth,
disableOAuth,
}
: cached;
if (activeEntry !== cached) {
this.clients.set(existingKey, activeEntry);
}
this.activeClientKeys.set(normalized, existingKey);
return activeEntry.promise;
}
}
let releaseConnectionSetup: (() => void) | undefined;
if (useCache && this.shouldSerializeConnectionSetup(definition, disableOAuth)) {
releaseConnectionSetup = await this.enterConnectionSetup(normalized);
try {
if (this.serverGeneration(normalized) !== generation) {
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
}
const refreshedDefinition = this.definitions.get(normalized);
if (!refreshedDefinition) {
throw new Error(`Unknown MCP server '${normalized}'.`);
}
definition = refreshedDefinition;
ignoresAuthCachePolicy = this.ignoresAuthCachePolicy(definition);
cacheAllowCachedAuth = ignoresAuthCachePolicy ? undefined : effectiveAllowCachedAuth;
cacheDisableOAuth = ignoresAuthCachePolicy ? false : disableOAuth;
cacheKey = this.cacheKey(normalized, cacheAllowCachedAuth, cacheDisableOAuth);
const existing = this.findCachedEntryForRequest(
normalized,
definition,
ignoresAuthCachePolicy ? undefined : options.allowCachedAuth,
cacheAllowCachedAuth,
cacheDisableOAuth
);
if (existing) {
releaseConnectionSetup();
releaseConnectionSetup = undefined;
const [existingKey, cached] = existing;
this.activeClientKeys.set(normalized, existingKey);
return cached.promise;
}
await this.retireConflictingOAuthEntries(normalized, cacheKey);
if (this.serverGeneration(normalized) !== generation) {
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
}
const latestDefinition = this.definitions.get(normalized);
if (!latestDefinition) {
throw new Error(`Unknown MCP server '${normalized}'.`);
}
definition = latestDefinition;
} catch (error) {
releaseConnectionSetup?.();
releaseConnectionSetup = undefined;
throw error;
}
}
let connectionDefinition = definition;
let contextPromise = createClientContext(definition, this.logger, this.clientInfo, {
const connection = createClientContext(definition, this.logger, this.clientInfo, {
maxOAuthAttempts: options.maxOAuthAttempts,
oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS,
onDefinitionPromoted: (promoted) => {
if (
this.serverGeneration(normalized) === generation &&
this.definitions.get(normalized) === connectionDefinition
) {
this.definitions.set(promoted.name, promoted);
connectionDefinition = promoted;
}
},
allowCachedAuth: effectiveAllowCachedAuth,
onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted),
allowCachedAuth: options.allowCachedAuth,
oauthSessionOptions: options.oauthSessionOptions,
disableOAuth,
recordPath: this.recordPath,
replayPath: this.replayPath,
});
if (useCache) {
const previousActiveKey = this.activeClientKeys.get(normalized);
contextPromise = contextPromise.then((context) => {
this.contextCacheKeys.set(context, cacheKey);
this.contextCachePromises.set(context, contextPromise);
return context;
});
let connection!: Promise<ClientContext>;
connection = contextPromise.then((context) => {
const stillCached = this.clients.get(cacheKey)?.promise === connection;
if (this.serverGeneration(normalized) !== generation || !stillCached) {
this.contextCacheKeys.delete(context);
this.contextCachePromises.delete(context);
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
}
return context;
});
this.activeClientKeys.set(normalized, cacheKey);
this.clients.set(cacheKey, {
server: normalized,
promise: connection,
contextPromise,
allowCachedAuth: ignoresAuthCachePolicy ? effectiveAllowCachedAuth : cacheAllowCachedAuth,
disableOAuth: ignoresAuthCachePolicy ? disableOAuth : cacheDisableOAuth,
});
this.clients.set(normalized, { promise: connection, allowCachedAuth: options.allowCachedAuth });
try {
return await connection;
} catch (error) {
const ownsCacheEntry = this.clients.get(cacheKey)?.promise === connection;
if (ownsCacheEntry) {
this.clients.delete(cacheKey);
if (
this.activeClientKeys.get(normalized) === cacheKey &&
previousActiveKey &&
this.clients.has(previousActiveKey)
) {
this.activeClientKeys.set(normalized, previousActiveKey);
} else if (
this.activeClientKeys.get(normalized) === cacheKey ||
this.cachedEntriesForServer(normalized).length === 0
) {
this.activeClientKeys.delete(normalized);
}
}
this.clients.delete(normalized);
throw error;
} finally {
releaseConnectionSetup?.();
}
}
releaseConnectionSetup?.();
return contextPromise;
return connection;
}
// close tears down transports (and OAuth sessions) for a single server or all servers.
async close(server?: string): Promise<void> {
if (server) {
const normalized = server.trim();
this.bumpServerGeneration(normalized);
const entries = [...this.clients.entries()].filter(([, cached]) => cached.server === normalized);
if (entries.length === 0) {
this.activeClientKeys.delete(normalized);
const cached = this.clients.get(normalized);
if (!cached) {
return;
}
for (const [key] of entries) {
this.clients.delete(key);
}
this.activeClientKeys.delete(normalized);
if (entries.length > 0) {
void this.trackRetirement(normalized, this.closeCachedEntries(entries.map(([, cached]) => cached)));
}
await this.awaitRetirements(normalized);
const context = await cached.promise;
await context.client.close().catch(() => {});
await closeTransportAndWait(this.logger, context.transport).catch(() => {});
await context.oauthSession?.close().catch(() => {});
this.clients.delete(normalized);
return;
}
this.bumpAllServerGenerations();
const entries = [...this.clients.entries()];
this.clients.clear();
this.activeClientKeys.clear();
const byServer = new Map<string, CachedClientEntry[]>();
for (const [, cached] of entries) {
const serverEntries = byServer.get(cached.server) ?? [];
serverEntries.push(cached);
byServer.set(cached.server, serverEntries);
}
for (const [serverName, serverEntries] of byServer) {
void this.trackRetirement(serverName, this.closeCachedEntries(serverEntries));
}
await this.awaitRetirements();
}
private contextPromiseFor(cached: CachedClientEntry): Promise<ClientContext> {
return cached.contextPromise ?? cached.promise;
}
private async closeCachedEntries(entries: CachedClientEntry[]): Promise<void> {
const results = await Promise.allSettled(
entries.map(async (cached) => {
const context = await this.contextPromiseFor(cached);
try {
await this.closeContext(context);
} finally {
this.contextCacheKeys.delete(context);
this.contextCachePromises.delete(context);
}
})
);
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
if (firstFailure) {
throw firstFailure.reason;
}
}
private async closeContext(context: ClientContext): Promise<void> {
const propagateReplayCloseErrors = context.transport instanceof ReplayTransport;
let closeError: unknown;
try {
await context.client.close();
} catch (error) {
if (propagateReplayCloseErrors) {
closeError ??= error;
for (const [name, cached] of this.clients.entries()) {
try {
const context = await cached.promise;
await context.client.close().catch(() => {});
await closeTransportAndWait(this.logger, context.transport).catch(() => {});
await context.oauthSession?.close().catch(() => {});
} finally {
this.clients.delete(name);
}
}
try {
await closeTransportAndWait(this.logger, context.transport, {
throwOnCloseError: propagateReplayCloseErrors,
});
} catch (error) {
if (propagateReplayCloseErrors) {
closeError ??= error;
}
}
await context.oauthSession?.close().catch(() => {});
if (closeError) {
throw closeError;
}
}
private async resetConnectionOnError(server: string, error: unknown, failedContext?: ClientContext): Promise<void> {
private async resetConnectionOnError(server: string, error: unknown): Promise<void> {
if (!shouldResetConnection(error)) {
return;
}
const normalized = server.trim();
if (!failedContext) {
if (!this.clients.has(normalized)) {
return;
}
try {
const failedKey = this.contextCacheKeys.get(failedContext);
const failedEntry = failedKey ? this.clients.get(failedKey) : undefined;
const failedContextPromise = this.contextCachePromises.get(failedContext);
if (
!failedKey ||
failedEntry?.server !== normalized ||
!failedContextPromise ||
this.contextPromiseFor(failedEntry) !== failedContextPromise
) {
return;
}
if (this.clients.get(failedKey)?.promise !== failedEntry.promise) {
return;
}
this.clients.delete(failedKey);
if (this.activeClientKeys.get(normalized) === failedKey || this.cachedEntriesForServer(normalized).length === 0) {
this.activeClientKeys.delete(normalized);
}
try {
await this.closeContext(failedContext);
} finally {
this.contextCacheKeys.delete(failedContext);
this.contextCachePromises.delete(failedContext);
}
// Reuse the existing close() helper so transport shutdown stays consistent with
// normal runtime disposal (wait for STDIO children, close OAuth sessions, etc.).
await this.close(normalized);
} catch (closeError) {
const detail = closeError instanceof Error ? closeError.message : String(closeError);
this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`);
}
}
private findCachedEntryForRequest(
server: string,
definition: ServerDefinition,
requestedAllowCachedAuth: boolean | undefined,
effectiveAllowCachedAuth: boolean | undefined,
disableOAuth: boolean
): [string, CachedClientEntry] | undefined {
const exactKey = this.cacheKey(server, effectiveAllowCachedAuth, disableOAuth);
if (this.ignoresAuthCachePolicy(definition)) {
const exact = this.clients.get(exactKey);
return exact ? [exactKey, exact] : undefined;
}
if (requestedAllowCachedAuth !== undefined) {
const exact = this.clients.get(exactKey);
return exact ? [exactKey, exact] : undefined;
}
const activeKey = this.activeClientKeys.get(server);
const active = activeKey ? this.clients.get(activeKey) : undefined;
const policyMatches = (cached: CachedClientEntry) =>
effectiveAllowCachedAuth === undefined || cached.allowCachedAuth === effectiveAllowCachedAuth;
if (activeKey && active?.server === server && active.disableOAuth === disableOAuth && policyMatches(active)) {
return [activeKey, active];
}
const matches = [...this.clients.entries()].filter(
([, cached]) => cached.server === server && cached.disableOAuth === disableOAuth && policyMatches(cached)
);
if (matches.length === 1) {
return matches[0];
}
const exact = this.clients.get(exactKey);
return exact ? [exactKey, exact] : undefined;
}
private async retireConflictingOAuthEntries(server: string, keepKey: string): Promise<void> {
const conflicting = [...this.clients.entries()].filter(
([key, cached]) => key !== keepKey && cached.server === server && !cached.disableOAuth
);
if (conflicting.length === 0) {
return;
}
for (const [key] of conflicting) {
this.clients.delete(key);
if (this.activeClientKeys.get(server) === key) {
this.activeClientKeys.delete(server);
}
}
await this.trackRetirement(server, this.closeCachedEntries(conflicting.map(([, cached]) => cached)));
}
private shouldSerializeConnectionSetup(definition: ServerDefinition, disableOAuth: boolean): boolean {
return definition.command.kind === 'http' && !disableOAuth && !this.ignoresAuthCachePolicy(definition);
}
private ignoresAuthCachePolicy(definition: ServerDefinition): boolean {
const replayServer = process.env.MCPORTER_REPLAY_SERVER;
const replaysDefinition = Boolean(this.replayPath) && (!replayServer || replayServer === definition.name);
return definition.command.kind === 'stdio' || replaysDefinition;
}
private trackRetirement(server: string, retirement: Promise<void>): Promise<void> {
const pending = this.retirementPromises.get(server) ?? new Set<Promise<void>>();
pending.add(retirement);
this.retirementPromises.set(server, pending);
const cleanup = () => {
pending.delete(retirement);
if (pending.size === 0) {
this.retirementPromises.delete(server);
}
};
retirement.then(cleanup, cleanup);
return retirement;
}
private async awaitRetirements(server?: string): Promise<void> {
const pending = server ? [...(this.retirementPromises.get(server) ?? [])] : [];
if (!server) {
for (const retirements of this.retirementPromises.values()) {
pending.push(...retirements);
}
}
const results = await Promise.allSettled(pending);
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
if (firstFailure) {
throw firstFailure.reason;
}
}
private async enterConnectionSetup(server: string): Promise<() => void> {
const previous = this.connectionSetupTails.get(server) ?? Promise.resolve();
let releaseCurrent!: () => void;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const tail = previous.catch(() => {}).then(() => current);
this.connectionSetupTails.set(server, tail);
await previous.catch(() => {});
let released = false;
return () => {
if (released) {
return;
}
released = true;
releaseCurrent();
void tail.finally(() => {
if (this.connectionSetupTails.get(server) === tail) {
this.connectionSetupTails.delete(server);
}
});
};
}
private cacheKey(server: string, allowCachedAuth: boolean | undefined, disableOAuth: boolean): string {
const cachedAuthKey =
allowCachedAuth === true ? 'cached-auth-on' : allowCachedAuth === false ? 'cached-auth-off' : 'cached-auth-unset';
return `${server}\u0000oauth-disabled:${disableOAuth ? '1' : '0'}\u0000${cachedAuthKey}`;
}
}
// createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL.

View File

@ -1,181 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ChildProcess } from 'node:child_process';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import { legacyMcporterDir } from '../paths.js';
export interface RecordTransportOptions {
readonly inner: Transport;
readonly recordPath: string;
readonly server: string;
}
export interface RecordingMeta {
readonly dir: 'send' | 'recv' | 'lifecycle';
readonly server: string;
readonly ts: string;
}
export type RecordedMessage = JSONRPCMessage & {
readonly _meta?: RecordingMeta;
};
const initializedRecordingPaths = new Map<string, Promise<void>>();
export const PRIVATE_RECORDING_DIR_MODE = 0o700;
export const PRIVATE_RECORDING_FILE_MODE = 0o600;
export class RecordTransport implements Transport {
onclose?: Transport['onclose'];
onerror?: Transport['onerror'];
onmessage?: Transport['onmessage'];
sessionId?: string;
finishAuth?: (authorizationCode: string) => Promise<void>;
private writes: Promise<void> = Promise.resolve();
private closeRecorded = false;
constructor(private readonly opts: RecordTransportOptions) {
this.sessionId = opts.inner.sessionId;
const finishAuth = (opts.inner as { finishAuth?: (authorizationCode: string) => Promise<void> }).finishAuth;
if (finishAuth) {
this.finishAuth = (authorizationCode) => finishAuth.call(opts.inner, authorizationCode);
}
}
get pid(): number | null {
const pid = (this.opts.inner as { pid?: unknown }).pid;
return typeof pid === 'number' && pid > 0 ? pid : null;
}
get _process(): ChildProcess | null {
return (this.opts.inner as { _process?: ChildProcess | null })._process ?? null;
}
async start(): Promise<void> {
await initializeRecordingFile(this.opts.recordPath);
this.opts.inner.onclose = () => {
void this.appendCloseOnce();
this.onclose?.();
};
this.opts.inner.onerror = (error) => {
this.onerror?.(error);
};
this.opts.inner.onmessage = (message) => {
void this.appendLine(this.withMeta(message, 'recv'));
this.onmessage?.(message);
};
await this.appendLifecycle('start');
await this.opts.inner.start();
this.sessionId = this.opts.inner.sessionId;
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
await this.appendLine(this.withMeta(message, 'send'));
await this.opts.inner.send(message, options);
}
async close(): Promise<void> {
await this.appendCloseOnce();
await this.opts.inner.close();
await this.writes;
}
setProtocolVersion(version: string): void {
this.opts.inner.setProtocolVersion?.(version);
}
private async appendLifecycle(event: 'start' | 'close'): Promise<void> {
await this.appendLine(
this.withMeta(
{
jsonrpc: '2.0',
method: `$transport/${event}`,
},
'lifecycle'
)
);
}
private async appendCloseOnce(): Promise<void> {
if (this.closeRecorded) {
return;
}
this.closeRecorded = true;
await this.appendLifecycle('close');
}
private withMeta(message: JSONRPCMessage, dir: RecordingMeta['dir']): RecordedMessage {
return {
...message,
_meta: {
dir,
server: this.opts.server,
ts: new Date().toISOString(),
},
};
}
private async appendLine(message: RecordedMessage): Promise<void> {
const line = `${JSON.stringify(message)}\n`;
this.writes = this.writes.then(async () => {
await ensurePrivateRecordingDir(this.opts.recordPath);
await fs.appendFile(this.opts.recordPath, line, {
encoding: 'utf8',
mode: PRIVATE_RECORDING_FILE_MODE,
});
});
await this.writes;
}
}
function initializeRecordingFile(recordPath: string): Promise<void> {
const existing = initializedRecordingPaths.get(recordPath);
if (existing) {
return existing;
}
const initialization = ensurePrivateRecordingDir(recordPath)
.then(() =>
fs.writeFile(recordPath, '', {
encoding: 'utf8',
mode: PRIVATE_RECORDING_FILE_MODE,
})
)
.then(() => fs.chmod(recordPath, PRIVATE_RECORDING_FILE_MODE))
.catch((error) => {
initializedRecordingPaths.delete(recordPath);
throw error;
});
initializedRecordingPaths.set(recordPath, initialization);
return initialization;
}
export async function ensurePrivateRecordingDir(recordPath: string): Promise<void> {
const recordingDir = path.dirname(recordPath);
await fs.mkdir(recordingDir, {
recursive: true,
mode: PRIVATE_RECORDING_DIR_MODE,
});
await fs.chmod(recordingDir, PRIVATE_RECORDING_DIR_MODE);
}
export function resolveRecordingPath(sessionName: string): string {
const normalized = normalizeRecordingSessionName(sessionName);
return path.join(legacyMcporterDir(), 'recordings', `${normalized}.ndjson`);
}
export function resolveRecordingConfigPath(sessionName: string): string {
const normalized = normalizeRecordingSessionName(sessionName);
return path.join(legacyMcporterDir(), 'recordings', `${normalized}.config.json`);
}
export function normalizeRecordingSessionName(sessionName: string): string {
const normalized = sessionName.trim();
if (!normalized) {
throw new Error('Recording session name is required.');
}
if (normalized.includes('/') || normalized.includes('\\') || normalized === '.' || normalized === '..') {
throw new Error(`Invalid recording session name '${sessionName}'. Use a simple file name without path separators.`);
}
return normalized;
}

View File

@ -1,198 +0,0 @@
import fs from 'node:fs';
import { isDeepStrictEqual } from 'node:util';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { RecordedMessage } from './record-transport.js';
export interface ReplayTransportOptions {
readonly recordPath: string;
readonly server: string;
}
interface ExpectedSend {
readonly method: string;
readonly params?: unknown;
readonly expectsResponse: boolean;
readonly response?: JSONRPCMessage;
}
type JsonRpcRecord = Record<string, unknown>;
export class ReplayTransport implements Transport {
onclose?: Transport['onclose'];
onerror?: Transport['onerror'];
onmessage?: Transport['onmessage'];
sessionId?: string;
private readonly expectedSends: ExpectedSend[];
constructor(private readonly opts: ReplayTransportOptions) {
this.expectedSends = buildReplayQueue(readRecordedMessages(opts.recordPath), opts.server);
}
async start(): Promise<void> {}
async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise<void> {
const request = requestDetails(message);
if (!request) {
return;
}
const expected = this.expectedSends[0];
if (!expected || expected.method !== request.method || !isDeepStrictEqual(expected.params, request.params)) {
throw new Error(formatReplayMismatch(this.opts.server, request, expected));
}
this.expectedSends.shift();
if (expected.response) {
const response = withActiveRequestId(expected.response, request.id);
queueMicrotask(() => this.onmessage?.(response));
}
}
async close(): Promise<void> {
if (this.expectedSends.length > 0) {
throw new Error(formatReplayRemainder(this.opts.server, this.expectedSends));
}
this.onclose?.();
}
}
function readRecordedMessages(recordPath: string): RecordedMessage[] {
try {
const contents = fs.readFileSync(recordPath, 'utf8');
return contents
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line, index) => {
try {
return JSON.parse(line) as RecordedMessage;
} catch (error) {
throw new Error(
`Invalid JSON on recording line ${index + 1} in ${recordPath}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
);
}
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Replay recording not found: ${recordPath}`, { cause: error });
}
throw error;
}
}
function buildReplayQueue(messages: RecordedMessage[], server: string): ExpectedSend[] {
const pendingRequests = new Map<string, ExpectedSend>();
const expected: ExpectedSend[] = [];
for (const entry of messages) {
if (entry._meta?.server !== server) {
continue;
}
if (entry._meta.dir === 'lifecycle') {
continue;
}
const clean = stripMeta(entry);
if (entry._meta.dir === 'send') {
const request = requestDetails(clean);
if (!request) {
continue;
}
const expectedSend: ExpectedSend = {
method: request.method,
params: request.params,
expectsResponse: request.id !== undefined,
};
expected.push(expectedSend);
if (request.id !== undefined) {
pendingRequests.set(String(request.id), expectedSend);
}
continue;
}
if (entry._meta.dir === 'recv') {
const responseId = responseIdOf(clean);
if (responseId === undefined) {
continue;
}
const pending = pendingRequests.get(String(responseId));
if (pending) {
pendingRequests.delete(String(responseId));
(pending as { response?: JSONRPCMessage }).response = clean;
}
}
}
return expected.filter((entry) => !entry.expectsResponse || entry.response);
}
function stripMeta(message: RecordedMessage): JSONRPCMessage {
const { _meta, ...jsonrpc } = message;
return jsonrpc as JSONRPCMessage;
}
function requestDetails(message: JSONRPCMessage):
| {
readonly id?: string | number;
readonly method: string;
readonly params?: unknown;
}
| undefined {
const record = message as JsonRpcRecord;
if (typeof record.method !== 'string') {
return undefined;
}
if (record.method.startsWith('$transport/')) {
return undefined;
}
return {
id: typeof record.id === 'string' || typeof record.id === 'number' ? record.id : undefined,
method: record.method,
params: record.params,
};
}
function responseIdOf(message: JSONRPCMessage): string | number | undefined {
const record = message as JsonRpcRecord;
if (!('result' in record) && !('error' in record)) {
return undefined;
}
const id = record.id;
return typeof id === 'string' || typeof id === 'number' ? id : undefined;
}
function withActiveRequestId(response: JSONRPCMessage, requestId: string | number | undefined): JSONRPCMessage {
if (requestId === undefined) {
return response;
}
return {
...(response as JsonRpcRecord),
id: requestId,
} as JSONRPCMessage;
}
function formatReplayMismatch(
server: string,
request: { readonly method: string; readonly params?: unknown },
expected: ExpectedSend | undefined
): string {
const expectedText = expected
? `${expected.method} ${JSON.stringify(expected.params ?? {})}`
: 'no remaining recorded recv';
return `Replay mismatch for server '${server}': request ${request.method} ${JSON.stringify(
request.params ?? {}
)} did not match next expected recv ${expectedText}.`;
}
function formatReplayRemainder(server: string, expectedSends: readonly ExpectedSend[]): string {
const expected = expectedSends[0];
const count = expectedSends.length;
const requestText = count === 1 ? 'request' : 'requests';
const expectedText = expected
? `${expected.method} ${JSON.stringify(expected.params ?? {})}`
: 'no remaining recorded recv';
return `Replay ended for server '${server}' with ${count} recorded ${requestText} still unused; next expected recv ${expectedText}.`;
}

View File

@ -21,8 +21,6 @@ import {
type OAuthCapableTransport,
OAuthTimeoutError,
} from './oauth.js';
import { RecordTransport } from './record-transport.js';
import { ReplayTransport } from './replay-transport.js';
import { resolveCommandArgument, resolveCommandArguments } from './utils.js';
const STDIO_TRACE_ENABLED = process.env.MCPORTER_STDIO_TRACE === '1';
@ -86,16 +84,6 @@ export interface CreateClientContextOptions {
readonly onDefinitionPromoted?: (definition: ServerDefinition) => void;
readonly allowCachedAuth?: boolean;
readonly oauthSessionOptions?: OAuthSessionOptions;
/**
* When `true`, suppress the interactive OAuth flow entirely. See
* `ConnectOptions.disableOAuth` in `runtime.ts` for the caller-facing
* semantics. Internally this short-circuits `shouldEstablishOAuth` and
* `maybePromoteHttpDefinition` so the unauthorized-fallback path
* cannot re-enable OAuth on a daemon-shaped caller.
*/
readonly disableOAuth?: boolean;
readonly recordPath?: string;
readonly replayPath?: string;
}
function removeAuthorizationHeader(headers: Record<string, string> | undefined): Record<string, string> | undefined {
@ -148,38 +136,6 @@ async function closeOAuthSession(oauthSession?: OAuthSession): Promise<void> {
await oauthSession?.close().catch(() => {});
}
function shouldUseModeForServer(definition: ServerDefinition, serverFilter: string | undefined): boolean {
return !serverFilter || serverFilter === definition.name;
}
function wrapRecordTransport<TTransport extends Transport>(
transport: TTransport,
definition: ServerDefinition,
options: CreateClientContextOptions
): TTransport {
if (!options.recordPath || !shouldUseModeForServer(definition, process.env.MCPORTER_RECORD_SERVER)) {
return transport;
}
return new RecordTransport({
inner: transport,
recordPath: options.recordPath,
server: definition.name,
}) as unknown as TTransport;
}
async function createReplayClientContext(
client: Client,
definition: ServerDefinition,
replayPath: string
): Promise<ClientContext> {
const transport = new ReplayTransport({
recordPath: replayPath,
server: definition.name,
});
await client.connect(transport);
return { client, transport, definition, oauthSession: undefined };
}
function shouldAbortSseFallback(error: unknown): boolean {
if (isPostAuthConnectError(error)) {
return !isLegacySseTransportMismatch(error);
@ -196,11 +152,7 @@ function maybePromoteHttpDefinition(
logger: Logger,
options: CreateClientContextOptions
): ServerDefinition | undefined {
// Both flags suppress promotion-to-OAuth on a 401 fallback. Without
// this guard, a daemon-mode caller hitting an unauthorized response
// could trigger `maybeEnableOAuth` and effectively re-enable OAuth
// on the next attempt — defeating the no-browser-launch contract.
if (options.maxOAuthAttempts === 0 || options.disableOAuth === true) {
if (options.maxOAuthAttempts === 0) {
return undefined;
}
return maybeEnableOAuth(definition, logger);
@ -299,8 +251,7 @@ async function applyCachedAuthIfAvailable(
async function createStdioClientContext(
client: Client,
definition: ServerDefinition & { command: Extract<ServerDefinition['command'], { kind: 'stdio' }> },
logger: Logger,
options: CreateClientContextOptions
logger: Logger
): Promise<ClientContext> {
const resolvedEnvOverrides =
definition.env && Object.keys(definition.env).length > 0
@ -320,16 +271,15 @@ async function createStdioClientContext(
if (compat.applied) {
logger.info(`Injecting chrome-devtools-mcp --autoConnect compatibility patch from ${compat.patchPath}.`);
}
const rawTransport = new StdioClientTransport({
const transport = new StdioClientTransport({
command,
args: commandArgs,
cwd: definition.command.cwd,
env: compat.env,
});
if (STDIO_TRACE_ENABLED) {
attachStdioTraceLogging(rawTransport, definition.name ?? definition.command.command);
attachStdioTraceLogging(transport, definition.name ?? definition.command.command);
}
const transport = wrapRecordTransport(rawTransport, definition, options);
try {
await client.connect(transport);
} catch (error) {
@ -367,8 +317,7 @@ async function attemptHttpClientContext(
throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`);
}
let oauthSession: OAuthSession | undefined;
const shouldEstablishOAuth =
activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0 && options.disableOAuth !== true;
const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0;
if (shouldEstablishOAuth) {
oauthSession = await createOAuthSession(activeDefinition, logger, options.oauthSessionOptions);
}
@ -427,8 +376,7 @@ async function connectPrimaryHttpTransport(
logger: Logger,
options: CreateClientContextOptions
): Promise<ClientContext> {
const createStreamableTransport = () =>
wrapRecordTransport(new StreamableHTTPClientTransport(command.url, transportOptions), definition, options);
const createStreamableTransport = () => new StreamableHTTPClientTransport(command.url, transportOptions);
const transport = await connectHttpTransport(client, createStreamableTransport(), oauthSession, logger, {
serverName: definition.name,
serverUrl: command.url,
@ -456,7 +404,7 @@ async function connectSseFallbackTransport(
try {
const transport = await connectHttpTransport(
client,
wrapRecordTransport(new SSEClientTransport(command.url, transportOptions), definition, options),
new SSEClientTransport(command.url, transportOptions),
oauthSession,
logger,
{
@ -493,9 +441,6 @@ export async function createClientContext(
options: CreateClientContextOptions = {}
): Promise<ClientContext> {
const client = new Client(clientInfo);
if (options.replayPath && shouldUseModeForServer(definition, process.env.MCPORTER_REPLAY_SERVER)) {
return createReplayClientContext(client, definition, options.replayPath);
}
const activeDefinition = await applyCachedAuthIfAvailable(definition, logger, options.allowCachedAuth);
return withEnvOverrides(activeDefinition.env, async () => {
@ -503,8 +448,7 @@ export async function createClientContext(
return createStdioClientContext(
client,
activeDefinition as ServerDefinition & { command: Extract<ServerDefinition['command'], { kind: 'stdio' }> },
logger,
options
logger
);
}
return retryHttpTransportWithFallback(client, activeDefinition, logger, options);

View File

@ -21,7 +21,6 @@ export interface ServeOptions {
readonly runtime: Pick<Runtime, 'listTools' | 'callTool'>;
readonly definitions: readonly ServerDefinition[];
readonly servers?: readonly string[];
readonly bare?: boolean;
}
export interface ServeStdioOptions extends ServeOptions {}
@ -54,28 +53,11 @@ export async function serveStdio(options: ServeStdioOptions): Promise<void> {
export async function serveHttp(options: ServeHttpOptions): Promise<http.Server> {
const httpServer = http.createServer((request, response) => {
const url = new URL(request.url ?? '/', `http://${DEFAULT_SERVE_HTTP_HOST}`);
let bridgeOptions: ServeOptions;
if (url.pathname === '/mcp') {
bridgeOptions = options;
} else if (url.pathname.startsWith('/mcp/')) {
let only: string;
try {
only = decodeURIComponent(url.pathname.slice('/mcp/'.length));
} catch {
response.writeHead(400).end('Bad request');
return;
}
const known = selectServedServers(options.definitions, options.servers).some((served) => served.name === only);
if (!known) {
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end(`Unknown server '${only}'`);
return;
}
bridgeOptions = { ...options, servers: [only], bare: true };
} else {
if (url.pathname !== '/mcp') {
response.writeHead(404).end('Not found');
return;
}
const bridgeServer = createBridgeServer(bridgeOptions);
const bridgeServer = createBridgeServer(options);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@ -108,14 +90,9 @@ export async function serveHttp(options: ServeHttpOptions): Promise<http.Server>
export function createBridgeServer(options: ServeOptions): McpServer {
const servedServers = selectServedServers(options.definitions, options.servers);
const [firstServed] = servedServers;
if (!firstServed) {
if (servedServers.length === 0) {
throw new Error('No keep-alive MCP servers are available to serve.');
}
const bare = options.bare === true;
if (bare && servedServers.length !== 1) {
throw new Error('Bare serve mode requires exactly one served server.');
}
const server = new McpServer(
{ name: 'mcporter-serve', version: MCPORTER_VERSION },
@ -123,9 +100,7 @@ export function createBridgeServer(options: ServeOptions): McpServer {
capabilities: {
tools: {},
} satisfies ServerCapabilities,
instructions: bare
? `MCPorter bridge exposing the '${firstServed.name}' server.`
: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
instructions: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
}
);
@ -144,8 +119,8 @@ export function createBridgeServer(options: ServeOptions): McpServer {
for (const tool of listed) {
tools.push({
name: bare ? tool.name : encodeToolName(served.name, tool.name),
description: bare ? tool.description : describeTool(served.name, tool.description),
name: encodeToolName(served.name, tool.name),
description: describeTool(served.name, tool.description),
inputSchema: normalizeInputSchema(tool.inputSchema),
outputSchema: normalizeOutputSchema(tool.outputSchema),
});
@ -155,9 +130,7 @@ export function createBridgeServer(options: ServeOptions): McpServer {
});
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const target = bare
? { server: firstServed.name, tool: request.params.name }
: decodeToolName(request.params.name, servedServers);
const target = decodeToolName(request.params.name, servedServers);
if (!target) {
throw new McpError(ErrorCode.InvalidParams, `Unknown bridged tool '${request.params.name}'.`);
}

View File

@ -17,16 +17,7 @@ type ToolSchemaInfo = {
propertySet: Set<string>;
};
const KNOWN_OPTION_KEYS = new Set([
'disableOAuth',
'tailLog',
'timeout',
'stream',
'streamLog',
'mimeType',
'metadata',
'log',
]);
const KNOWN_OPTION_KEYS = new Set(['tailLog', 'timeout', 'stream', 'streamLog', 'mimeType', 'metadata', 'log']);
export interface ServerProxyOptions {
readonly mapPropertyToTool?: (property: string | symbol) => string;
@ -52,51 +43,6 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isProxyOptionKey(key: string): boolean {
return key === 'args' || KNOWN_OPTION_KEYS.has(key);
}
function inferMetadataOptions(callArgs: unknown[]): {
options: { autoAuthorize?: false; disableOAuth?: boolean };
optionObjects: Set<Record<string, unknown>>;
} {
const options: { autoAuthorize?: false; disableOAuth?: boolean } = {};
const optionObjects = new Set<Record<string, unknown>>();
for (const [index, arg] of callArgs.entries()) {
if (!isPlainObject(arg) || arg.disableOAuth !== true) {
continue;
}
const keys = Object.keys(arg);
const isOptionsOnlyObject = keys.length > 0 && keys.every(isProxyOptionKey);
const hasClearlySeparateToolArgs = callArgs.some((other, otherIndex) => {
if (otherIndex === index) {
return false;
}
if (!isPlainObject(other)) {
return false;
}
return Object.hasOwn(other, 'args') || Object.keys(other).some((key) => !isProxyOptionKey(key));
});
// `args` plus proxy options is reserved envelope syntax; use proxy.call()
// when a tool schema itself owns both `args` and `disableOAuth`.
const hasExplicitArgsEnvelope = Object.hasOwn(arg, 'args');
// A sole object can be a tool argument whose schema owns `disableOAuth`.
// Multi-argument calls suppress discovery defensively, then let the schema
// classify option-only objects unless another argument is clearly tool input.
const isUnambiguousOptionsObject = isOptionsOnlyObject && (hasClearlySeparateToolArgs || hasExplicitArgsEnvelope);
if (isUnambiguousOptionsObject) {
options.disableOAuth = true;
} else if (isOptionsOnlyObject && callArgs.length > 1 && options.disableOAuth !== true) {
options.autoAuthorize = false;
}
if (isUnambiguousOptionsObject) {
optionObjects.add(arg);
}
}
return { options, optionObjects };
}
// createToolSchemaInfo normalizes schema metadata used for argument mapping.
function createToolSchemaInfo(schemaRaw: unknown): ToolSchemaInfo | undefined {
if (!schemaRaw || typeof schemaRaw !== 'object') {
@ -199,7 +145,7 @@ export function createServerProxy(
const toolSchemaCache = new Map<string, ToolSchemaInfo>();
const persistedSchemas = new Map<string, Record<string, unknown>>();
const toolAliasMap = new Map<string, string>();
const schemaFetches = new Map<string, Promise<void>>();
let schemaFetch: Promise<void> | null = null;
let diskLoad: Promise<void> | null = null;
let persistPromise: Promise<void> | null = null;
let refreshPending = false;
@ -238,13 +184,7 @@ export function createServerProxy(
}
// ensureMetadata loads schema information for the requested tool, optionally refreshing from the server.
// Unambiguous proxy options use cache-friendly OAuth suppression. Ambiguous
// option-shaped arguments use an uncached no-authorize fetch so discovery
// cannot launch OAuth or change the runtime's active connection posture.
async function ensureMetadata(
toolName: string,
metadataOptions: { autoAuthorize?: false; disableOAuth?: boolean } = {}
): Promise<ToolSchemaInfo | undefined> {
async function ensureMetadata(toolName: string): Promise<ToolSchemaInfo | undefined> {
await consumePersist();
const cached = toolSchemaCache.get(toolName);
if (cached && !refreshPending) {
@ -262,28 +202,9 @@ export function createServerProxy(
}
}
const disableOAuth = metadataOptions.disableOAuth === true;
const schemaFetchKey = disableOAuth
? 'disable-oauth'
: metadataOptions.autoAuthorize === false
? 'no-authorize'
: 'default';
let schemaFetch = schemaFetches.get(schemaFetchKey);
if (!schemaFetch) {
const listToolsOptions: {
includeSchema: true;
autoAuthorize?: false;
disableOAuth?: boolean;
} = {
includeSchema: true,
};
if (disableOAuth) {
listToolsOptions.disableOAuth = true;
} else if (metadataOptions.autoAuthorize === false) {
listToolsOptions.autoAuthorize = false;
}
schemaFetch = runtime
.listTools(serverName, listToolsOptions)
.listTools(serverName, { includeSchema: true })
.then((tools) => {
for (const tool of tools) {
if (!tool.inputSchema || typeof tool.inputSchema !== 'object') {
@ -295,12 +216,9 @@ export function createServerProxy(
refreshPending = false;
})
.catch((error) => {
if (schemaFetches.get(schemaFetchKey) === schemaFetch) {
schemaFetches.delete(schemaFetchKey);
}
schemaFetch = null;
throw error;
});
schemaFetches.set(schemaFetchKey, schemaFetch);
}
await schemaFetch;
@ -383,11 +301,9 @@ export function createServerProxy(
: mapPropertyToTool(propertyKey);
return async (...callArgs: unknown[]) => {
const { options: metadataOptions, optionObjects } = inferMetadataOptions(callArgs);
let schemaInfo: ToolSchemaInfo | undefined;
try {
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
schemaInfo = await ensureMetadata(resolvedToolName);
} catch {
schemaInfo = undefined;
}
@ -396,7 +312,7 @@ export function createServerProxy(
if (alias && alias !== resolvedToolName) {
resolvedToolName = alias;
try {
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
schemaInfo = await ensureMetadata(resolvedToolName);
} catch {
// ignore and keep prior schema if available
}
@ -411,7 +327,6 @@ export function createServerProxy(
if (isPlainObject(arg)) {
const keys = Object.keys(arg);
const treatAsArgs =
!optionObjects.has(arg) &&
schemaInfo !== undefined &&
keys.length > 0 &&
(keys.every((key) => schemaInfo.propertySet.has(key)) ||

View File

@ -1,6 +1,4 @@
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';
@ -95,55 +93,6 @@ 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.");
});
@ -226,12 +175,6 @@ describe('parseCallArguments', () => {
expect(parsed.positionalArgs).toEqual(['123']);
});
it('captures --no-oauth as a runtime flag instead of a tool argument', () => {
const parsed = parseCallArguments(['server.tool', '--no-oauth', 'limit=5']);
expect(parsed.disableOAuth).toBe(true);
expect(parsed.args).toEqual({ limit: 5 });
});
it('captures --save-images output directory', () => {
const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
expect(parsed.saveImagesDir).toBe('./tmp/images');

View File

@ -86,7 +86,6 @@ describe('CLI call execution behavior', () => {
autoAuthorize: true,
includeSchema: true,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
@ -126,7 +125,6 @@ describe('CLI call execution behavior', () => {
autoAuthorize: true,
includeSchema: true,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
@ -292,133 +290,6 @@ 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 {
@ -467,7 +338,6 @@ describe('CLI call execution behavior', () => {
autoAuthorize: true,
includeSchema: false,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
@ -575,7 +445,6 @@ 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 ?? []) {
@ -590,9 +459,6 @@ 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) => {
@ -602,10 +468,12 @@ function createRuntimeStub(
}
return definition;
}),
registerDefinition,
registerDefinition: vi.fn().mockImplementation((definition: ServerDefinition) => {
definitions.set(definition.name, definition);
}),
listTools,
callTool,
close,
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
return { runtime, callTool, listTools, registerDefinition };
return { runtime, callTool, listTools };
}

View File

@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
process.env.MCPORTER_DISABLE_AUTORUN = '1';
@ -33,12 +33,9 @@ vi.mock('../src/runtime.js', () => ({
createRuntime: mocks.createRuntime,
}));
const originalEnv = { ...process.env };
describe('daemon call fast path', () => {
beforeEach(() => {
vi.restoreAllMocks();
process.env = { ...originalEnv };
mocks.DaemonClient.mockClear();
mocks.createRuntime.mockClear();
mocks.daemonCallTool.mockReset().mockResolvedValue({
@ -49,10 +46,6 @@ describe('daemon call fast path', () => {
process.exitCode = undefined;
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('routes explicit default keep-alive calls without building the full runtime', async () => {
const { runCli } = await import('../src/cli.js');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@ -88,30 +81,4 @@ describe('daemon call fast path', () => {
})
);
});
it('leaves CloudBase calls on the config-aware runtime path', async () => {
mocks.createRuntime.mockRejectedValue(new Error('runtime path used'));
const { runCli } = await import('../src/cli.js');
await expect(runCli(['call', 'cloudbase.auth', '--output', 'json'])).rejects.toThrow('runtime path used');
expect(mocks.createRuntime).toHaveBeenCalled();
expect(mocks.daemonCallTool).not.toHaveBeenCalled();
});
it.each(['MCPORTER_RECORD', 'MCPORTER_REPLAY'] as const)(
'bypasses the daemon fast path while %s is active',
async (modeEnv) => {
process.env[modeEnv] = 'demo';
mocks.createRuntime.mockRejectedValue(new Error('runtime path used'));
const { runCli } = await import('../src/cli.js');
await expect(runCli(['call', 'chrome-devtools.list_pages', '--output', 'json'])).rejects.toThrow(
'runtime path used'
);
expect(mocks.createRuntime).toHaveBeenCalled();
expect(mocks.daemonCallTool).not.toHaveBeenCalled();
}
);
});

View File

@ -10,13 +10,6 @@ describe('cli flag utils', () => {
expect(argv).toEqual(['list']);
});
it('preserves flags after the command separator for wrapped commands', () => {
const argv = ['record', 'demo', '--', 'node', 'dist/cli.js', '--config', '/tmp/child.json', 'call'];
const flags = extractFlags(argv, ['--config']);
expect(flags['--config']).toBeUndefined();
expect(argv).toEqual(['record', 'demo', '--', 'node', 'dist/cli.js', '--config', '/tmp/child.json', 'call']);
});
it('throws when a required flag value is missing', () => {
expect(() => extractFlags(['--config'], ['--config'])).toThrow(/requires a value/);
expect(() => expectValue('--output', undefined)).toThrow(/requires a value/);

View File

@ -34,50 +34,12 @@ async function ensureDistBuilt(): Promise<void> {
async function hasBun(): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
execFile(process.env.BUN_BIN ?? 'bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
execFile('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}.`);
@ -90,17 +52,6 @@ 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[],
@ -615,7 +566,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 ensureRunnableBunCompile('compile integration test'))) {
if (!(await ensureBunSupport('compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-'));
@ -665,7 +616,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('end-to-end: compiles a "bun" CLI and calls ping', async () => {
if (!(await ensureRunnableBunCompile('Bun CLI end-to-end test'))) {
if (!(await ensureBunSupport('Bun CLI end-to-end test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-bun-'));
@ -739,7 +690,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 ensureRunnableBunCompile('Bun bundler compile integration test'))) {
if (!(await ensureBunSupport('Bun bundler compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-bun-'));
@ -788,7 +739,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 ensureRunnableBunCompile('inline stdio compile integration test'))) {
if (!(await ensureBunSupport('inline stdio compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-inline-stdio-'));
@ -933,7 +884,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 ensureRunnableBunCompile('standalone Bun release binary smoke'))) {
if (!(await ensureBunSupport('standalone Bun release binary smoke'))) {
return;
}
await new Promise<void>((resolve, reject) => {

View File

@ -45,17 +45,4 @@ describe('mcporter help shortcuts (hidden)', () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(expectSnippet));
expect(process.exitCode).toBe(0);
});
it.each([
['serve', '--help'],
['serve', 'help'],
])('prints serve HTTP endpoint help for %j', async (...args) => {
const { runCli } = await cliModulePromise;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await runCli(args);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('/mcp/<server>'));
expect(process.exitCode).toBe(0);
});
});

View File

@ -1,222 +0,0 @@
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);
});

View File

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

View File

@ -260,7 +260,6 @@ describe('CLI list classification and routing', () => {
expect(listTools).toHaveBeenCalledWith('linear', {
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: false,
});
});
@ -331,8 +330,6 @@ describe('CLI list classification and routing', () => {
it('suggests a server name when the typo is large', async () => {
const { handleList } = await cliModulePromise;
const previousExitCode = process.exitCode;
process.exitCode = undefined;
const definition = linearDefinition;
const listTools = vi.fn();
const runtime = {
@ -346,17 +343,13 @@ describe('CLI list classification and routing', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await handleList(runtime, ['zzz']);
await handleList(runtime, ['zzz']);
const errorLines = errorSpy.mock.calls.map((call) => call.join(' '));
expect(errorLines.some((line) => line.includes('Did you mean linear?'))).toBe(true);
expect(listTools).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
logSpy.mockRestore();
process.exitCode = previousExitCode;
}
const errorLines = errorSpy.mock.calls.map((call) => call.join(' '));
expect(errorLines.some((line) => line.includes('Did you mean linear?'))).toBe(true);
expect(listTools).not.toHaveBeenCalled();
errorSpy.mockRestore();
logSpy.mockRestore();
});
});

View File

@ -19,7 +19,6 @@ describe('CLI list flag parsing', () => {
quiet: false,
exitCode: false,
statusOnly: false,
disableOAuth: false,
});
expect(args).toEqual(['server']);
});
@ -40,19 +39,10 @@ describe('CLI list flag parsing', () => {
quiet: false,
exitCode: false,
statusOnly: false,
disableOAuth: false,
});
expect(args).toEqual(['server']);
});
it('parses --no-oauth and removes it from args', async () => {
const { extractListFlags } = await cliModulePromise;
const args = ['--no-oauth', 'server'];
const flags = extractListFlags(args);
expect(flags.disableOAuth).toBe(true);
expect(args).toEqual(['server']);
});
it('parses --json flag and removes it from args', async () => {
const { extractListFlags } = await cliModulePromise;
const args = ['--json', 'server'];

View File

@ -148,50 +148,6 @@ 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 = {

View File

@ -37,26 +37,20 @@ function createRuntime(): Runtime {
describe('handleList JSON output', () => {
it('emits aggregated status counts', async () => {
const runtime = createRuntime();
const previousExitCode = process.exitCode;
process.exitCode = undefined;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await runHandleList(runtime, ['--json']);
await runHandleList(runtime, ['--json']);
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.mode).toBe('list');
expect(payload.counts.auth).toBe(1);
const healthyEntry = payload.servers.find((entry: { name: string }) => entry.name === 'healthy');
expect(healthyEntry.status).toBe('ok');
const authEntry = payload.servers.find((entry: { name: string }) => entry.name === 'auth-server');
expect(authEntry.status).toBe('auth');
expect(authEntry.issue.kind).toBe('auth');
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
process.exitCode = previousExitCode;
}
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.mode).toBe('list');
expect(payload.counts.auth).toBe(1);
const healthyEntry = payload.servers.find((entry: { name: string }) => entry.name === 'healthy');
expect(healthyEntry.status).toBe('ok');
const authEntry = payload.servers.find((entry: { name: string }) => entry.name === 'auth-server');
expect(authEntry.status).toBe('auth');
expect(authEntry.issue.kind).toBe('auth');
logSpy.mockRestore();
});
it('sets a non-zero exit code for unhealthy multi-server checks when requested', async () => {

View File

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

View File

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

View File

@ -53,17 +53,6 @@ describe('handleResource', () => {
}
});
it('passes disableOAuth to resource helpers when requested', async () => {
const runtime = createRuntime();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await handleResource(runtime, ['docs', 'memo://one', '--no-oauth']);
expect(runtime.readResource).toHaveBeenCalledWith('docs', 'memo://one', { disableOAuth: true });
} finally {
logSpy.mockRestore();
}
});
it('prints structured JSON for resource listing failures', async () => {
const runtime = createRuntime();
runtime.listResources.mockRejectedValue(new Error('MCP error -32601: Method not found'));

View File

@ -1,238 +0,0 @@
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);
});

View File

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

View File

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

View File

@ -40,7 +40,7 @@ function buildResponse(method: string, id: string) {
id,
ok: true,
result: {
pid: activeStatusPid,
pid: 123,
startedAt: Date.now(),
configPath: activeConfigPath,
configMtimeMs: activeConfigMtime,
@ -59,7 +59,6 @@ function buildResponse(method: string, id: string) {
let activeConfigPath: string;
let activeConfigMtime: number | null = null;
let activeStatusPid = process.pid;
let activeSocketPath: string;
let previousDaemonDir: string | undefined;
let activeLayers: Array<{ path: string; mtimeMs: number | null }> = [];
@ -85,28 +84,6 @@ describe('DaemonClient config freshness', () => {
previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
activeLayers = [];
launchDaemonDetached.mockClear();
launchDaemonDetached.mockImplementation(
(options: { metadataPath: string; socketPath: string; configPath: string }) => {
activeStatusPid = process.pid;
void fs.writeFile(
options.metadataPath,
JSON.stringify(
{
pid: process.pid,
socketPath: options.socketPath,
configPath: options.configPath,
startedAt: Date.now(),
logPath: null,
configMtimeMs: activeConfigMtime,
configLayers: activeLayers,
},
null,
2
),
'utf8'
);
}
);
});
afterEach(async () => {
@ -125,12 +102,10 @@ describe('DaemonClient config freshness', () => {
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
const stat = await fs.stat(configPath);
const oldMtime = stat.mtimeMs - 1000;
const deadPid = findNonRunningPid();
const { metadataPath, socketPath } = resolveDaemonPaths(configPath);
activeConfigPath = configPath;
activeSocketPath = socketPath;
activeConfigMtime = stat.mtimeMs;
activeStatusPid = deadPid;
activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }];
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
@ -138,7 +113,7 @@ describe('DaemonClient config freshness', () => {
metadataPath,
JSON.stringify(
{
pid: deadPid,
pid: 1111,
socketPath,
configPath,
startedAt: Date.now() - 10_000,
@ -168,12 +143,10 @@ describe('DaemonClient config freshness', () => {
const configPath = path.join(tmpDir, 'config.json');
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
const stat = await fs.stat(configPath);
const deadPid = findNonRunningPid();
const { metadataPath, socketPath } = resolveDaemonPaths(configPath);
activeConfigPath = configPath;
activeSocketPath = socketPath;
activeConfigMtime = stat.mtimeMs;
activeStatusPid = deadPid;
activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }];
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
@ -181,7 +154,7 @@ describe('DaemonClient config freshness', () => {
metadataPath,
JSON.stringify(
{
pid: deadPid,
pid: 1111,
socketPath,
configPath,
startedAt: Date.now() - 10_000,
@ -216,7 +189,6 @@ describe('DaemonClient config freshness', () => {
activeConfigPath = configPath;
activeSocketPath = socketPath;
activeConfigMtime = stat.mtimeMs;
activeStatusPid = process.pid;
activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }];
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
@ -224,7 +196,7 @@ describe('DaemonClient config freshness', () => {
metadataPath,
JSON.stringify(
{
pid: process.pid,
pid: 1111,
socketPath,
configPath,
startedAt: Date.now() - 10_000,
@ -241,21 +213,8 @@ describe('DaemonClient config freshness', () => {
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
await client.listTools({ server: 'playwright' });
expect(sentMethods).toEqual(['status', 'listTools']);
expect(sentMethods).toEqual(['listTools']);
expect(sentMethods).not.toContain('stop');
expect(launchDaemonDetached).not.toHaveBeenCalled();
});
});
function findNonRunningPid(): number {
for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) {
try {
process.kill(pid, 0);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
return pid;
}
}
}
throw new Error('Unable to find a non-running pid for daemon tests.');
}

View File

@ -1,230 +0,0 @@
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { makeShortTempDir } from './fixtures/test-helpers.js';
const launchDaemonDetached = vi.hoisted(() => vi.fn());
vi.mock('../src/daemon/launch.js', () => ({
launchDaemonDetached,
}));
const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js');
interface MockDaemonOptions {
readonly configPath: string;
readonly socketPath: string;
readonly metadataPath: string;
}
const servers: net.Server[] = [];
let previousDaemonDir: string | undefined;
describe('DaemonClient lifecycle reconciliation', () => {
beforeEach(() => {
previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
launchDaemonDetached.mockReset();
launchDaemonDetached.mockImplementation((options: MockDaemonOptions) => {
void startMockDaemon(options, process.pid);
});
});
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => closeServer(server)));
if (previousDaemonDir === undefined) {
delete process.env.MCPORTER_DAEMON_DIR;
} else {
process.env.MCPORTER_DAEMON_DIR = previousDaemonDir;
}
});
it('serializes concurrent daemon starts with a filesystem lock', async () => {
const tmpDir = await makeShortTempDir('daemon-lock');
process.env.MCPORTER_DAEMON_DIR = tmpDir;
const configPath = path.join(tmpDir, 'config.json');
await fs.writeFile(
configPath,
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
'utf8'
);
const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]);
expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
});
it('rejects socket responders that do not match metadata pid', async () => {
const tmpDir = await makeShortTempDir('daemon-pid');
process.env.MCPORTER_DAEMON_DIR = tmpDir;
const configPath = path.join(tmpDir, 'config.json');
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
const { socketPath, metadataPath } = resolveDaemonPaths(configPath);
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
await fs.writeFile(
metadataPath,
JSON.stringify({
pid: process.pid,
socketPath,
configPath,
configLayers: [{ path: configPath, mtimeMs: (await fs.stat(configPath)).mtimeMs }],
startedAt: Date.now(),
}),
'utf8'
);
await startStatusServer(socketPath, process.pid + 10_000, configPath);
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
await expect(client.status()).resolves.toBeNull();
});
it('forces a new daemon after a request transport failure even when status still responds', async () => {
const tmpDir = await makeShortTempDir('daemon-restart');
process.env.MCPORTER_DAEMON_DIR = tmpDir;
const configPath = path.join(tmpDir, 'config.json');
await fs.writeFile(
configPath,
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
'utf8'
);
const paths = resolveDaemonPaths(configPath);
await startMockDaemon({ ...paths, configPath }, process.pid, { failCallTool: true });
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
const result = await client.callTool({ server: 'warm', tool: 'list' });
expect(result).toEqual({ ok: true });
expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent stale-config restarts after the first replacement wins', async () => {
const tmpDir = await makeShortTempDir('daemon-stale-lock');
process.env.MCPORTER_DAEMON_DIR = tmpDir;
const configPath = path.join(tmpDir, 'config.json');
await fs.writeFile(
configPath,
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
'utf8'
);
const stat = await fs.stat(configPath);
const deadPid = findNonRunningPid();
const paths = resolveDaemonPaths(configPath);
await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true });
await fs.writeFile(
paths.metadataPath,
JSON.stringify({
pid: deadPid,
socketPath: paths.socketPath,
configPath,
configLayers: [{ path: configPath, mtimeMs: stat.mtimeMs - 1000 }],
startedAt: Date.now() - 10_000,
}),
'utf8'
);
const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]);
expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
});
});
async function startMockDaemon(
options: MockDaemonOptions,
pid: number,
behavior: { failCallTool?: boolean } = {}
): Promise<void> {
const stat = await fs.stat(options.configPath);
await startStatusServer(options.socketPath, pid, options.configPath, options.metadataPath, behavior);
await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
await fs.writeFile(
options.metadataPath,
JSON.stringify({
pid,
socketPath: options.socketPath,
configPath: options.configPath,
configLayers: [{ path: options.configPath, mtimeMs: stat.mtimeMs }],
startedAt: Date.now(),
}),
'utf8'
);
}
async function startStatusServer(
socketPath: string,
pid: number,
configPath: string,
metadataPath?: string,
behavior: { failCallTool?: boolean } = {}
): Promise<void> {
await fs.mkdir(path.dirname(socketPath), { recursive: true });
await fs.unlink(socketPath).catch(() => {});
const server = net.createServer((socket) => {
let buffer = '';
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
const request = JSON.parse(buffer) as { id: string; method: string };
if (request.method === 'callTool' && behavior.failCallTool) {
behavior.failCallTool = false;
socket.destroy();
return;
}
if (request.method === 'stop') {
socket.end(JSON.stringify({ id: request.id, ok: true, result: true }), () => {
server.close(() => {});
if (metadataPath) {
void fs.unlink(metadataPath).catch(() => {});
}
});
return;
}
const result =
request.method === 'status'
? {
pid,
startedAt: Date.now(),
configPath,
socketPath,
servers: [],
}
: request.method === 'callTool'
? { ok: true }
: { tools: [] };
socket.end(JSON.stringify({ id: request.id, ok: true, result }));
});
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, () => {
server.off('error', reject);
servers.push(server);
resolve();
});
});
}
async function closeServer(server: net.Server): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve()).on('error', () => resolve());
});
}
function findNonRunningPid(): number {
for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) {
try {
process.kill(pid, 0);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
return pid;
}
}
}
throw new Error('Unable to find a non-running pid for daemon tests.');
}

View File

@ -1,8 +1,5 @@
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { makeShortTempDir } from './fixtures/test-helpers.js';
const timeoutRecords: Array<{ method: string; timeout: number }> = [];
@ -37,8 +34,6 @@ class MockSocket extends EventEmitter {
}
let responseDelayMs = 5;
let activeConfigPath = path.resolve('mcporter.config.json');
let activeSocketPath = '';
const createConnection = vi.fn(() => {
const socket = new MockSocket();
setTimeout(() => socket.emit('connect'), 0);
@ -46,8 +41,6 @@ const createConnection = vi.fn(() => {
});
let previousDaemonTimeout: string | undefined;
let previousDaemonDir: string | undefined;
let tmpDaemonDir: string | undefined;
vi.mock('node:net', () => ({
createConnection,
@ -58,7 +51,7 @@ vi.mock('../src/daemon/launch.js', () => ({
launchDaemonDetached: vi.fn(),
}));
const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js');
const { DaemonClient } = await import('../src/daemon/client.js');
function buildResponse(method: string, id: string) {
if (method === 'status') {
@ -66,10 +59,10 @@ function buildResponse(method: string, id: string) {
id,
ok: true,
result: {
pid: process.pid,
pid: 123,
startedAt: Date.now(),
configPath: activeConfigPath,
socketPath: activeSocketPath,
configPath: 'test',
socketPath: '/tmp/socket',
servers: [],
},
};
@ -82,92 +75,40 @@ function buildResponse(method: string, id: string) {
}
describe('DaemonClient timeouts', () => {
beforeEach(async () => {
beforeEach(() => {
timeoutRecords.length = 0;
responseDelayMs = 5;
previousDaemonTimeout = process.env.MCPORTER_DAEMON_TIMEOUT_MS;
previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
tmpDaemonDir = await makeShortTempDir('daemon-timeout');
process.env.MCPORTER_DAEMON_DIR = tmpDaemonDir;
delete process.env.MCPORTER_DAEMON_TIMEOUT_MS;
});
afterEach(async () => {
afterEach(() => {
if (previousDaemonTimeout === undefined) {
delete process.env.MCPORTER_DAEMON_TIMEOUT_MS;
} else {
process.env.MCPORTER_DAEMON_TIMEOUT_MS = previousDaemonTimeout;
}
if (previousDaemonDir === undefined) {
delete process.env.MCPORTER_DAEMON_DIR;
} else {
process.env.MCPORTER_DAEMON_DIR = previousDaemonDir;
}
if (tmpDaemonDir) {
await fs.rm(tmpDaemonDir, { recursive: true, force: true });
}
});
it('defaults to 30s per request', async () => {
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
const client = new DaemonClient({ configPath: 'mcporter.config.json' });
await client.callTool({ server: 'foo', tool: 'bar' });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(30_000);
expect(callRecord?.timeout).toBe(30_000);
});
it('honors MCPORTER_DAEMON_TIMEOUT_MS override', async () => {
process.env.MCPORTER_DAEMON_TIMEOUT_MS = '4500';
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
const client = new DaemonClient({ configPath: 'mcporter.config.json' });
await client.callTool({ server: 'foo', tool: 'bar' });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(4_500);
expect(callRecord?.timeout).toBe(4_500);
});
it('honors per-call timeout overrides', async () => {
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
const client = new DaemonClient({ configPath: 'mcporter.config.json' });
await client.callTool({ server: 'foo', tool: 'bar', timeoutMs: 12_345 });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(12_345);
expect(callRecord?.timeout).toBe(12_345);
});
it('clamps daemon status preflight timeout for tiny per-call timeouts', async () => {
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
await client.callTool({ server: 'foo', tool: 'bar', timeoutMs: 1 });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(1_000);
expect(callRecord?.timeout).toBe(1);
});
});
async function writeFreshMetadata(configPath: string): Promise<void> {
activeConfigPath = path.resolve(configPath);
const paths = resolveDaemonPaths(configPath);
activeSocketPath = paths.socketPath;
await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true });
await fs.writeFile(
paths.metadataPath,
JSON.stringify({
pid: process.pid,
socketPath: paths.socketPath,
configPath,
configLayers: [{ path: activeConfigPath, mtimeMs: null }],
startedAt: Date.now(),
}),
'utf8'
);
}

View File

@ -90,7 +90,7 @@ describe('daemon client', () => {
}
});
it('verifies daemon pid before trusting fresh metadata', async () => {
it('skips status preflight when daemon metadata is fresh', async () => {
const tmpDir = await makeShortTempDir('mcpd-fresh');
const originalDir = process.env.MCPORTER_DAEMON_DIR;
process.env.MCPORTER_DAEMON_DIR = tmpDir;
@ -121,18 +121,7 @@ describe('daemon client', () => {
buffer += chunk;
const request = JSON.parse(buffer) as { id: string; method: string };
methods.push(request.method);
const result =
request.method === 'status'
? {
pid: process.pid,
startedAt: Date.now(),
configPath,
configLayers: [{ path: configPath, mtimeMs: configStats.mtimeMs }],
socketPath,
servers: [],
}
: { tools: [] };
socket.end(JSON.stringify({ id: request.id, ok: true, result }));
socket.end(JSON.stringify({ id: request.id, ok: true, result: { tools: [] } }));
});
});
await new Promise<void>((resolve, reject) => {
@ -145,7 +134,7 @@ describe('daemon client', () => {
try {
const client = new DaemonClient({ configPath, configExplicit: true });
await client.listTools({ server: 'warm' });
expect(methods).toEqual(['status', 'listTools']);
expect(methods).toEqual(['listTools']);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await fs.unlink(socketPath).catch(() => {});

View File

@ -1,16 +1,6 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import {
__testProcessRequest,
cleanupDaemonArtifactsIfOwned,
isDaemonResponding,
metadataMatches,
} from '../src/daemon/host.js';
import { __testProcessRequest } from '../src/daemon/host.js';
import type { DaemonRequest } from '../src/daemon/protocol.js';
import type { Runtime } from '../src/runtime.js';
@ -54,7 +44,6 @@ describe('daemon host request handling', () => {
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
args: {},
timeoutMs: undefined,
disableOAuth: false,
});
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
@ -67,73 +56,6 @@ describe('daemon host request handling', () => {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('keeps stdio keep-alive listTools requests reusable when callers disable auto auth', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'local', includeSchema: true, autoAuthorize: false, allowCachedAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('local', {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('preserves HTTP listTools auto-auth opt out on daemon requests', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', includeSchema: true, autoAuthorize: false, allowCachedAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('forwards disableOAuth on daemon callTool and listTools requests', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'call',
method: 'callTool',
params: { server: 'oauth', tool: 'ping', disableOAuth: true },
});
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
args: {},
timeoutMs: undefined,
disableOAuth: true,
});
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', includeSchema: true, disableOAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: true,
});
});
@ -151,145 +73,10 @@ describe('daemon host request handling', () => {
includeSchema: undefined,
autoAuthorize: undefined,
allowCachedAuth: false,
disableOAuth: false,
});
});
});
const describeUnixSocket = process.platform === 'win32' ? describe.skip : describe;
describeUnixSocket('isDaemonResponding', () => {
const servers: net.Server[] = [];
const connections: net.Socket[] = [];
const socketPaths: string[] = [];
function socketPath(): string {
const p = path.join(os.tmpdir(), `mcporter-probe-${randomUUID().slice(0, 8)}.sock`);
socketPaths.push(p);
return p;
}
function listen(server: net.Server, p: string): Promise<void> {
servers.push(server);
server.on('connection', (socket) => connections.push(socket));
return new Promise((resolve) => server.listen(p, () => resolve()));
}
afterEach(async () => {
for (const socket of connections.splice(0)) {
socket.destroy();
}
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
for (const p of socketPaths.splice(0)) {
await fs.rm(p, { force: true }).catch(() => {});
}
});
it('returns true when the socket answers status with a matching socket and live pid', async () => {
const p = socketPath();
await listen(statusServer({ pid: process.pid, socketPath: p }), p);
expect(await isDaemonResponding(p)).toBe(true);
});
it('returns false when the socket accepts but never responds (hung daemon)', async () => {
const p = socketPath();
await listen(
net.createServer((socket) => socket.pause()),
p
);
expect(await isDaemonResponding(p)).toBe(false);
}, 5_000);
it('returns false when status reports a different socket (foreign listener)', async () => {
const p = socketPath();
await listen(statusServer({ pid: process.pid, socketPath: '/some/other/daemon.sock' }), p);
expect(await isDaemonResponding(p)).toBe(false);
});
it('returns false when status reports a dead pid', async () => {
const p = socketPath();
await listen(statusServer({ pid: 2_147_483_646, socketPath: p }), p);
expect(await isDaemonResponding(p)).toBe(false);
});
it('returns false when nothing is listening', async () => {
expect(await isDaemonResponding(socketPath())).toBe(false);
});
});
describe('metadataMatches', () => {
let metadataPath: string;
const live = { pid: 4321, socketPath: '/tmp/daemon.sock' };
beforeEach(async () => {
metadataPath = path.join(os.tmpdir(), `mcporter-meta-${randomUUID().slice(0, 8)}.json`);
});
afterEach(async () => {
await fs.rm(metadataPath, { force: true }).catch(() => {});
});
it('matches when pid and socket agree', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath: '/tmp/daemon.sock' }), 'utf8');
expect(await metadataMatches(metadataPath, live)).toBe(true);
});
it('does not match a different pid', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9999, socketPath: '/tmp/daemon.sock' }), 'utf8');
expect(await metadataMatches(metadataPath, live)).toBe(false);
});
it('does not match when metadata is missing', async () => {
expect(await metadataMatches(metadataPath, live)).toBe(false);
});
it('does not match when metadata is corrupt', async () => {
await fs.writeFile(metadataPath, '{ not json', 'utf8');
expect(await metadataMatches(metadataPath, live)).toBe(false);
});
});
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 }),
@ -299,14 +86,6 @@ function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
function createManagedServers(): Map<string, ServerDefinition> {
return new Map([
[
'local',
{
name: 'local',
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
lifecycle: { mode: 'keep-alive' },
},
],
[
'oauth',
{
@ -317,9 +96,3 @@ function createManagedServers(): Map<string, ServerDefinition> {
],
]);
}
function statusServer(result: Record<string, unknown>): net.Server {
return net.createServer((socket) => {
socket.on('data', () => socket.end(JSON.stringify({ id: '1', ok: true, result })));
});
}

View File

@ -1,4 +1,4 @@
import { type ChildProcess, execFile, spawn } from 'node:child_process';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import os from 'node:os';
@ -29,20 +29,6 @@ async function readFileWithRetries(filePath: string, retries = 20, delayMs = 100
throw lastError ?? new Error(`Failed to read ${filePath}`);
}
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForExit(child: ChildProcess, retries = 50, delayMs = 100): Promise<void> {
for (let attempt = 0; attempt < retries; attempt++) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
await delay(delayMs);
}
throw new Error(`Process ${child.pid ?? '<unknown>'} did not exit.`);
}
async function ensureDistBuilt(): Promise<void> {
try {
await fs.access(CLI_ENTRY);
@ -91,10 +77,8 @@ describeDaemon('daemon keep-alive integration', () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-e2e-'));
const scriptPath = path.join(tempDir, 'daemon-server.mjs');
const configPath = path.join(tempDir, 'mcporter.daemon.json');
const launchLogPath = path.join(tempDir, 'launches.log');
const stdioServerSource = `import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { McpServer } from '${MCP_SERVER_MODULE}';
import { StdioServerTransport } from '${STDIO_SERVER_MODULE}';
import { z } from '${ZOD_MODULE}';
@ -102,10 +86,6 @@ import { z } from '${ZOD_MODULE}';
const instanceId = randomUUID();
let counter = 0;
if (process.env.MCPORTER_TEST_LAUNCH_LOG) {
await fs.appendFile(process.env.MCPORTER_TEST_LAUNCH_LOG, instanceId + '\\n', 'utf8');
}
const server = new McpServer({ name: 'daemon-e2e', version: '1.0.0' });
server.registerTool('next_value', {
title: 'Next value',
@ -155,16 +135,12 @@ await new Promise((resolve) => {
MCPORTER_DAEMON_LOG: '1',
MCPORTER_DAEMON_LOG_PATH: logPath,
MCPORTER_DAEMON_LOG_SERVERS: 'daemon-e2e',
MCPORTER_TEST_LAUNCH_LOG: launchLogPath,
};
const cli = (args: string[]) => runCli(args, configPath, cliEnv);
try {
await cli(['daemon', 'stop']);
await cli(['list', 'daemon-e2e', '--json']);
await cli(['list', 'daemon-e2e', '--json']);
const first = await cli(['call', 'daemon-e2e.next_value', '--output', 'json']);
const firstResult = parseCliJson(first.stdout);
expect(firstResult.count).toBe(1);
@ -174,12 +150,7 @@ await new Promise((resolve) => {
expect(secondResult.count).toBe(2);
expect(secondResult.instanceId).toBe(firstResult.instanceId);
const launchLog = await readFileWithRetries(launchLogPath);
expect(launchLog.trim().split('\n')).toEqual([firstResult.instanceId]);
const logContents = await readFileWithRetries(logPath);
expect(logContents).toContain('listTools start server=daemon-e2e');
expect(logContents).toContain('listTools success server=daemon-e2e');
expect(logContents).toContain('callTool start server=daemon-e2e tool=next_value');
expect(logContents).toContain('callTool success server=daemon-e2e tool=next_value');
} finally {
@ -187,283 +158,4 @@ await new Promise((resolve) => {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}, 40_000);
it('refuses duplicate binds when foreground starts race outside the client lock', async () => {
await ensureDistBuilt();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-bind-'));
const scriptPath = path.join(tempDir, 'bind-server.mjs');
const configPath = path.join(tempDir, 'mcporter.bind.json');
const serverSource = `import { McpServer } from '${MCP_SERVER_MODULE}';
import { StdioServerTransport } from '${STDIO_SERVER_MODULE}';
const server = new McpServer({ name: 'bind-e2e', version: '1.0.0' });
server.registerTool('ping', { title: 'ping', description: 'ping', inputSchema: {} }, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
await server.connect(new StdioServerTransport());
await new Promise(() => {});
`;
await fs.writeFile(scriptPath, serverSource, 'utf8');
await fs.writeFile(
configPath,
JSON.stringify({
mcpServers: {
'bind-e2e': { description: 'bind race server', command: 'node', args: [scriptPath], lifecycle: 'keep-alive' },
},
}),
'utf8'
);
const children: ChildProcess[] = [];
try {
await runCli(['daemon', 'stop'], configPath).catch(() => {});
for (let i = 0; i < 4; i++) {
children.push(
spawn(process.execPath, [CLI_ENTRY, '--config', configPath, 'daemon', 'start', '--foreground'], {
env: { ...process.env, MCPORTER_NO_FORCE_EXIT: '1' },
stdio: 'ignore',
})
);
}
await new Promise((resolve) => setTimeout(resolve, 4_000));
const alive = children.filter((child) => child.exitCode === null && child.signalCode === null);
expect(alive).toHaveLength(1);
} finally {
for (const child of children) {
child.kill('SIGKILL');
}
await runCli(['daemon', 'stop'], configPath).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}, 40_000);
it('repairs metadata when a live daemon owns the socket and metadata is missing', async () => {
await ensureDistBuilt();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-meta-'));
const scriptPath = path.join(tempDir, 'meta-server.mjs');
const configPath = path.join(tempDir, 'mcporter.meta.json');
const metadataPath = path.join(tempDir, 'daemon.json');
const serverSource = `import { McpServer } from '${MCP_SERVER_MODULE}';
import { StdioServerTransport } from '${STDIO_SERVER_MODULE}';
const server = new McpServer({ name: 'meta-e2e', version: '1.0.0' });
server.registerTool('ping', { title: 'ping', description: 'ping', inputSchema: {} }, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
await server.connect(new StdioServerTransport());
await new Promise(() => {});
`;
await fs.writeFile(scriptPath, serverSource, 'utf8');
await fs.writeFile(
configPath,
JSON.stringify({
mcpServers: {
'meta-e2e': { description: 'meta server', command: 'node', args: [scriptPath], lifecycle: 'keep-alive' },
},
}),
'utf8'
);
// Pin only the metadata path (not the socket, to stay under the unix socket length limit).
const env = { ...process.env, MCPORTER_NO_FORCE_EXIT: '1', MCPORTER_DAEMON_METADATA: metadataPath };
const children: ChildProcess[] = [];
const startForeground = (): ChildProcess => {
const child = spawn(process.execPath, [CLI_ENTRY, '--config', configPath, 'daemon', 'start', '--foreground'], {
env,
stdio: 'ignore',
});
children.push(child);
return child;
};
try {
const first = startForeground();
const firstPid = JSON.parse(await readFileWithRetries(metadataPath, 50)).pid as number;
expect(firstPid).toBe(first.pid);
await fs.rm(metadataPath, { force: true });
const replacement = startForeground();
await waitForExit(replacement);
const ownerPid = JSON.parse(await readFileWithRetries(metadataPath, 50)).pid as number;
expect(ownerPid).toBe(first.pid);
expect(first.exitCode).toBeNull();
} finally {
for (const child of children) {
child.kill('SIGKILL');
}
await runCli(['daemon', 'stop'], configPath, { MCPORTER_DAEMON_METADATA: metadataPath }).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}, 40_000);
it('stops a live daemon with stale config before rebinding', async () => {
await ensureDistBuilt();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-stale-'));
const scriptPath = path.join(tempDir, 'stale-server.mjs');
const configPath = path.join(tempDir, 'mcporter.stale.json');
const metadataPath = path.join(tempDir, 'daemon.json');
const serverSource = `import { McpServer } from '${MCP_SERVER_MODULE}';
import { StdioServerTransport } from '${STDIO_SERVER_MODULE}';
const server = new McpServer({ name: 'stale-e2e', version: '1.0.0' });
server.registerTool('ping', { title: 'ping', description: 'ping', inputSchema: {} }, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
await server.connect(new StdioServerTransport());
await new Promise(() => {});
`;
await fs.writeFile(scriptPath, serverSource, 'utf8');
const writeConfig = async (description: string): Promise<void> => {
await fs.writeFile(
configPath,
JSON.stringify({
mcpServers: {
'stale-e2e': { description, command: 'node', args: [scriptPath], lifecycle: 'keep-alive' },
},
}),
'utf8'
);
};
await writeConfig('stale server v1');
const env = { ...process.env, MCPORTER_NO_FORCE_EXIT: '1', MCPORTER_DAEMON_METADATA: metadataPath };
const children: ChildProcess[] = [];
const startForeground = (): ChildProcess => {
const child = spawn(process.execPath, [CLI_ENTRY, '--config', configPath, 'daemon', 'start', '--foreground'], {
env,
stdio: 'ignore',
});
children.push(child);
return child;
};
try {
const first = startForeground();
const firstPid = JSON.parse(await readFileWithRetries(metadataPath, 50)).pid as number;
expect(firstPid).toBe(first.pid);
await delay(20);
await writeConfig('stale server v2');
const staleConfigTime = new Date(Date.now() + 5_000);
await fs.utimes(configPath, staleConfigTime, staleConfigTime);
const replacement = startForeground();
await waitForExit(first);
const ownerPid = JSON.parse(await readFileWithRetries(metadataPath, 50)).pid as number;
expect(ownerPid).toBe(replacement.pid);
expect(replacement.exitCode).toBeNull();
} finally {
for (const child of children) {
child.kill('SIGKILL');
}
await runCli(['daemon', 'stop'], configPath, { MCPORTER_DAEMON_METADATA: metadataPath }).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}, 40_000);
it('stops a live daemon when imported root definitions change', async () => {
await ensureDistBuilt();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-root-'));
const scriptPath = path.join(tempDir, 'root-server.mjs');
const configPath = path.join(tempDir, 'mcporter.root.json');
const metadataPath = path.join(tempDir, 'daemon.json');
const socketPath = path.join(tempDir, 'daemon.sock');
const rootA = path.join(tempDir, 'root-a');
const rootB = path.join(tempDir, 'root-b');
const fakeHome = path.join(tempDir, 'home');
const serverSource = `import { McpServer } from '${MCP_SERVER_MODULE}';
import { StdioServerTransport } from '${STDIO_SERVER_MODULE}';
const server = new McpServer({ name: 'root-e2e', version: '1.0.0' });
server.registerTool('ping', { title: 'ping', description: 'ping', inputSchema: {} }, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
await server.connect(new StdioServerTransport());
await new Promise(() => {});
`;
await fs.writeFile(scriptPath, serverSource, 'utf8');
await fs.writeFile(configPath, JSON.stringify({ imports: ['cursor'], mcpServers: {} }), 'utf8');
const writeCursorImport = async (rootDir: string, name: string): Promise<void> => {
const importPath = path.join(rootDir, '.cursor', 'mcp.json');
await fs.mkdir(path.dirname(importPath), { recursive: true });
await fs.writeFile(
importPath,
JSON.stringify({
mcpServers: {
[name]: {
description: name,
command: 'node',
args: [scriptPath],
lifecycle: 'keep-alive',
},
},
}),
'utf8'
);
};
await writeCursorImport(rootA, 'root-a-e2e');
await writeCursorImport(rootB, 'root-b-e2e');
const env = {
...process.env,
MCPORTER_NO_FORCE_EXIT: '1',
MCPORTER_DAEMON_METADATA: metadataPath,
MCPORTER_DAEMON_SOCKET: socketPath,
MCPORTER_KEEPALIVE: '*',
HOME: fakeHome,
XDG_CONFIG_HOME: path.join(tempDir, 'xdg'),
APPDATA: path.join(tempDir, 'appdata'),
};
const children: ChildProcess[] = [];
const startForeground = (rootDir: string): ChildProcess => {
const child = spawn(
process.execPath,
[CLI_ENTRY, '--config', configPath, '--root', rootDir, 'daemon', 'start', '--foreground'],
{
env,
stdio: 'ignore',
}
);
children.push(child);
return child;
};
try {
const first = startForeground(rootA);
const firstMetadata = JSON.parse(await readFileWithRetries(metadataPath, 50)) as {
pid: number;
definitionHash?: string;
};
expect(firstMetadata.pid).toBe(first.pid);
expect(typeof firstMetadata.definitionHash).toBe('string');
const replacement = startForeground(rootB);
await waitForExit(first);
const replacementMetadata = JSON.parse(await readFileWithRetries(metadataPath, 50)) as {
pid: number;
definitionHash?: string;
};
expect(replacementMetadata.pid).toBe(replacement.pid);
expect(replacementMetadata.definitionHash).not.toBe(firstMetadata.definitionHash);
expect(replacement.exitCode).toBeNull();
} finally {
for (const child of children) {
child.kill('SIGKILL');
}
await runCli(['daemon', 'stop'], configPath, {
MCPORTER_DAEMON_METADATA: metadataPath,
MCPORTER_DAEMON_SOCKET: socketPath,
MCPORTER_KEEPALIVE: '*',
HOME: fakeHome,
XDG_CONFIG_HOME: path.join(tempDir, 'xdg'),
APPDATA: path.join(tempDir, 'appdata'),
}).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}, 40_000);
});

View File

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

View File

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

View File

@ -238,39 +238,6 @@ describe('fs-json helpers', () => {
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
});
it('applies the timeout while waiting for a same-process lock', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
let enter!: () => void;
let unblock!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const blocked = new Promise<void>((resolve) => {
unblock = resolve;
});
const holder = withFileLock(lockTarget, async () => {
enter();
await blocked;
});
await entered;
await expect(withFileLock(lockTarget, async () => {}, { timeoutMs: 50 })).rejects.toThrow(
/Timed out waiting for file lock/
);
let followerEntered = false;
const follower = withFileLock(lockTarget, async () => {
followerEntered = true;
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(followerEntered).toBe(false);
unblock();
await Promise.all([holder, follower]);
expect(followerEntered).toBe(true);
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
});
it('recovers lock files left by dead processes', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
await fs.writeFile(`${lockTarget}.lock`, '99999999\n2026-01-01T00:00:00.000Z\n', 'utf8');

View File

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

View File

@ -203,9 +203,9 @@ describeGenerateCli('generateCli', () => {
});
await fs.mkdir(path.join(tmpDir, 'schema-cache'), { recursive: true });
const exec = await import('node:child_process');
const bunAvailable = await hasRunnableBunCompile(exec);
const bunAvailable = await hasBun(exec);
if (!bunAvailable) {
console.warn('bun-compiled binaries cannot run on this runner; skipping compilation checks.');
console.warn('bun is not available on this runner; skipping compilation checks.');
return;
}
await ensureDistBuilt();
@ -747,16 +747,17 @@ describeGenerateCli('generateCli', () => {
}, 30_000);
it('accepts both kebab-case and underscore tool names for generated CLIs', async () => {
const serverRef = JSON.stringify({
name: 'tool-alias-test',
description: 'Tool alias test',
command: baseUrl.toString(),
const deepwikiRef = JSON.stringify({
name: 'deepwiki',
description: 'DeepWiki MCP',
command: 'https://mcp.deepwiki.com/mcp',
tokenCacheDir: path.join(tmpDir, 'deepwiki-cache'),
});
const outputPath = path.join(tmpDir, 'tool-alias-test.ts');
const outputPath = path.join(tmpDir, 'deepwiki-cli.ts');
await fs.rm(outputPath, { force: true });
const { outputPath: renderedPath } = await generateCli({
serverRef,
serverRef: deepwikiRef,
outputPath,
runtime: 'node',
timeoutMs: 10_000,
@ -780,14 +781,14 @@ describeGenerateCli('generateCli', () => {
);
});
expect(helpOutput).toMatch(/list-comments/);
expect(helpOutput).not.toMatch(/list_comments/);
expect(helpOutput).toMatch(/read-wiki-structure/);
expect(helpOutput).not.toMatch(/read_wiki_structure/);
// underscore alias should still work
await new Promise<void>((resolve, reject) => {
execFile(
'pnpm',
['exec', 'tsx', renderedPath, 'list_comments', '--help'],
['exec', 'tsx', renderedPath, 'read_wiki_structure', '--help'],
execOptions(),
(error: import('node:child_process').ExecFileException | null) => {
if (error) {
@ -803,7 +804,7 @@ describeGenerateCli('generateCli', () => {
await new Promise<void>((resolve, reject) => {
execFile(
'pnpm',
['exec', 'tsx', renderedPath, 'list-comments', '--help'],
['exec', 'tsx', renderedPath, 'read-wiki-structure', '--help'],
execOptions(),
(error: import('node:child_process').ExecFileException | null) => {
if (error) {
@ -890,38 +891,3 @@ async function hasBun(exec: typeof import('node:child_process')) {
});
});
}
let bunCompileSupport: Promise<boolean> | undefined;
async function hasRunnableBunCompile(exec: typeof import('node:child_process')) {
bunCompileSupport ??= probeRunnableBunCompile(exec);
return await bunCompileSupport;
}
async function probeRunnableBunCompile(exec: typeof import('node:child_process')) {
if (!(await hasBun(exec))) {
return false;
}
const tempDir = await fs.mkdtemp(path.join(tmpDir, 'bun-compile-probe-'));
const sourcePath = path.join(tempDir, 'probe.ts');
const binaryPath = path.join(tempDir, 'probe');
try {
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
const bun = process.env.BUN_BIN ?? 'bun';
const built = await new Promise<boolean>((resolve) => {
exec.execFile(bun, ['build', sourcePath, '--compile', '--outfile', binaryPath], execOptions(), (error) =>
resolve(!error)
);
});
if (!built) {
return false;
}
return await new Promise<boolean>((resolve) => {
exec.execFile(binaryPath, [], execOptions(), (error, stdout) => {
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
});
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}

View File

@ -2,7 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { createKeepAliveRuntime } from '../src/daemon/runtime-wrapper.js';
import type { CallOptions, ConnectOptions, ListToolsOptions, Runtime } from '../src/runtime.js';
import type { CallOptions, ListToolsOptions, Runtime } from '../src/runtime.js';
class FakeRuntime implements Runtime {
private readonly definitions: ServerDefinition[];
@ -10,7 +10,6 @@ class FakeRuntime implements Runtime {
public readonly listToolsMock = vi.fn().mockResolvedValue([{ name: 'local-tool' }]);
public readonly listResourcesMock = vi.fn().mockResolvedValue([]);
public readonly readResourceMock = vi.fn().mockResolvedValue({ contents: [] });
public readonly connectMock = vi.fn().mockResolvedValue({ client: {}, transport: {}, definition: {} });
public readonly closeMock = vi.fn().mockResolvedValue(undefined);
constructor(definitions: ServerDefinition[]) {
@ -57,8 +56,8 @@ class FakeRuntime implements Runtime {
return await this.readResourceMock(server, uri);
}
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
return await this.connectMock(server, options);
async connect(): Promise<never> {
throw new Error('not implemented');
}
async close(server?: string): Promise<void> {
@ -103,7 +102,6 @@ describe('createKeepAliveRuntime', () => {
tool: 'ping',
args: { value: 1 },
timeoutMs: 4_200,
disableOAuth: undefined,
});
await keepAliveRuntime.listTools('alpha', { includeSchema: true });
@ -112,7 +110,6 @@ describe('createKeepAliveRuntime', () => {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: undefined,
});
await keepAliveRuntime.listTools('alpha', { allowCachedAuth: false });
@ -121,26 +118,15 @@ describe('createKeepAliveRuntime', () => {
includeSchema: undefined,
autoAuthorize: undefined,
allowCachedAuth: false,
disableOAuth: undefined,
});
await keepAliveRuntime.listResources('alpha', { cursor: '1' });
expect(daemon.listResources).toHaveBeenCalledWith({
server: 'alpha',
params: { cursor: '1' },
allowCachedAuth: undefined,
disableOAuth: undefined,
});
expect(daemon.listResources).toHaveBeenCalledWith({ server: 'alpha', params: { cursor: '1' } });
await expect(keepAliveRuntime.readResource('alpha', 'memo://1')).resolves.toEqual({
contents: [{ uri: 'memo://1', text: 'daemon-resource' }],
});
expect(daemon.readResource).toHaveBeenCalledWith({
server: 'alpha',
uri: 'memo://1',
allowCachedAuth: undefined,
disableOAuth: undefined,
});
expect(daemon.readResource).toHaveBeenCalledWith({ server: 'alpha', uri: 'memo://1' });
await keepAliveRuntime.close('alpha');
expect(daemon.closeServer).toHaveBeenCalledWith({ server: 'alpha' });
@ -152,58 +138,6 @@ describe('createKeepAliveRuntime', () => {
expect(runtime.closeMock).toHaveBeenCalledWith(undefined);
});
it('forwards disableOAuth through daemon requests and connect wrappers', async () => {
const runtime = new FakeRuntime(definitions);
const daemon = {
callTool: vi.fn().mockResolvedValue('daemon-call'),
listTools: vi.fn().mockResolvedValue([{ name: 'remote-tool' }]),
listResources: vi.fn().mockResolvedValue(['resource']),
readResource: vi.fn().mockResolvedValue({ contents: [] }),
closeServer: vi.fn().mockResolvedValue(undefined),
};
const keepAliveRuntime = createKeepAliveRuntime(runtime as unknown as Runtime, {
daemonClient: daemon as never,
keepAliveServers: new Set(['alpha']),
});
await keepAliveRuntime.callTool('alpha', 'ping', { disableOAuth: true });
expect(daemon.callTool).toHaveBeenCalledWith({
server: 'alpha',
tool: 'ping',
args: undefined,
timeoutMs: undefined,
disableOAuth: true,
});
await keepAliveRuntime.listTools('alpha', { disableOAuth: true });
expect(daemon.listTools).toHaveBeenCalledWith({
server: 'alpha',
includeSchema: undefined,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: true,
});
await keepAliveRuntime.listResources('alpha', { cursor: '1', disableOAuth: true });
expect(daemon.listResources).toHaveBeenCalledWith({
server: 'alpha',
params: { cursor: '1' },
allowCachedAuth: undefined,
disableOAuth: true,
});
await keepAliveRuntime.readResource('alpha', 'memo://1', { disableOAuth: true });
expect(daemon.readResource).toHaveBeenCalledWith({
server: 'alpha',
uri: 'memo://1',
allowCachedAuth: undefined,
disableOAuth: true,
});
await keepAliveRuntime.connect('alpha', { disableOAuth: true });
expect(runtime.connectMock).toHaveBeenCalledWith('alpha', { disableOAuth: true });
});
it('restarts daemon servers after fatal errors and retries the operation', async () => {
const runtime = new FakeRuntime(definitions);
const daemon = {

View File

@ -16,20 +16,6 @@ const CHROME_COMMAND_ENV: CommandSpec = {
cwd: process.cwd(),
};
const CLOUDBASE_NPX_COMMAND: CommandSpec = {
kind: 'stdio',
command: 'npx',
args: ['-y', '@cloudbase/cloudbase-mcp@latest'],
cwd: process.cwd(),
};
const CLOUDBASE_BIN_COMMAND: CommandSpec = {
kind: 'stdio',
command: 'cloudbase-mcp',
args: [],
cwd: process.cwd(),
};
describe('resolveLifecycle', () => {
it('forces chrome-devtools placeholder runs to be ephemeral', () => {
const lifecycle = resolveLifecycle('chrome-devtools', undefined, CHROME_COMMAND);
@ -40,19 +26,4 @@ describe('resolveLifecycle', () => {
const lifecycle = resolveLifecycle('chrome-devtools', undefined, CHROME_COMMAND_ENV);
expect(lifecycle?.mode).toBe('ephemeral');
});
it('auto-enables keep-alive for CloudBase MCP package commands', () => {
const lifecycle = resolveLifecycle('cloudbase', undefined, CLOUDBASE_NPX_COMMAND);
expect(lifecycle?.mode).toBe('keep-alive');
});
it('auto-enables keep-alive for CloudBase MCP binary commands', () => {
const lifecycle = resolveLifecycle('tcb', undefined, CLOUDBASE_BIN_COMMAND);
expect(lifecycle?.mode).toBe('keep-alive');
});
it('honors explicit ephemeral lifecycle for CloudBase MCP commands', () => {
const lifecycle = resolveLifecycle('cloudbase', 'ephemeral', CLOUDBASE_NPX_COMMAND);
expect(lifecycle?.mode).toBe('ephemeral');
});
});

View File

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

View File

@ -44,30 +44,6 @@ 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);
@ -181,338 +157,6 @@ describe('oauth persistence', () => {
}
});
it('reuses same-url vault credentials after an OAuth server is renamed', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-rename-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: {
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
} as never,
clientInfo: {
client_id: 'client-123',
redirect_uris: ['http://127.0.0.1:44444/callback'],
},
});
await saveVaultEntry(currentDefinition, {
clientInfo: {
client_id: 'client-123',
redirect_uris: ['http://127.0.0.1:55555/callback'],
},
});
authMocks.discoverOAuthServerInfo.mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com',
authorizationServerMetadata: { token_endpoint: 'https://auth.example.com/token' },
resourceMetadata: { resource: 'https://example.com/mcp' },
});
authMocks.refreshAuthorization.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer',
refresh_token: 'refresh-456',
expires_in: 3600,
});
await expect(readCachedAccessToken(currentDefinition)).resolves.toBe('fresh-token');
expect(authMocks.refreshAuthorization).toHaveBeenCalledWith(
'https://auth.example.com',
expect.objectContaining({
clientInformation: expect.objectContaining({ client_id: 'client-123' }),
refreshToken: 'refresh-123',
resource: new URL('https://example.com/mcp'),
})
);
await expect(loadVaultEntry(currentDefinition)).resolves.toMatchObject({
serverName: 'cloudflare',
tokens: { access_token: 'fresh-token', refresh_token: 'refresh-456' },
clientInfo: { client_id: 'client-123' },
});
});
it('materializes inherited OAuth client info when renamed credentials refresh', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-rename-materialize-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: {
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
} as never,
clientInfo: { client_id: 'client-123' },
});
authMocks.discoverOAuthServerInfo.mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com',
authorizationServerMetadata: { token_endpoint: 'https://auth.example.com/token' },
});
authMocks.refreshAuthorization.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer',
refresh_token: 'refresh-456',
expires_in: 3600,
});
await expect(readCachedAccessToken(currentDefinition)).resolves.toBe('fresh-token');
await expect(loadVaultEntry(currentDefinition)).resolves.toMatchObject({
serverName: 'cloudflare',
tokens: { access_token: 'fresh-token', refresh_token: 'refresh-456' },
clientInfo: { client_id: 'client-123' },
});
});
it('materializes inherited OAuth client info into partial renamed vault entries', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-rename-partial-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: {
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
} as never,
clientInfo: { client_id: 'client-123' },
});
await saveVaultEntry(currentDefinition, { state: 'oauth-state' });
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer',
refresh_token: 'refresh-456',
expires_in: 3600,
});
await expect(readCachedAccessToken(currentDefinition)).resolves.toBe('fresh-token');
await expect(loadVaultEntry(currentDefinition)).resolves.toMatchObject({
state: 'oauth-state',
tokens: { access_token: 'fresh-token' },
clientInfo: { client_id: 'client-123' },
});
});
it('does not combine same-url OAuth tokens with a different dynamic client', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-client-mismatch-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-123' },
clientInfo: { client_id: 'old-client' },
});
await saveVaultEntry(currentDefinition, {
clientInfo: { client_id: 'current-client' },
});
await expect(loadVaultEntry(currentDefinition)).resolves.toMatchObject({
serverName: 'cloudflare',
clientInfo: { client_id: 'current-client' },
});
expect((await loadVaultEntry(currentDefinition))?.tokens).toBeUndefined();
await expect(readCachedAccessToken(currentDefinition)).resolves.toBeUndefined();
expect(authMocks.refreshAuthorization).not.toHaveBeenCalled();
});
it('does not inherit same-url OAuth tokens for a different configured client id', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-static-client-mismatch-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
await saveVaultEntry(mkDef('cloudflare-oauth'), {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-123' },
clientInfo: { client_id: 'old-client' },
});
const currentDefinition: ServerDefinition = {
...mkDef('cloudflare'),
oauthClientId: 'current-client',
};
await expect(loadVaultEntry(currentDefinition)).resolves.toBeUndefined();
await expect(readCachedAccessToken(currentDefinition)).resolves.toBeUndefined();
expect(authMocks.refreshAuthorization).not.toHaveBeenCalled();
});
it('uses one same-url vault entry for inherited OAuth tokens and client info', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-single-source-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
await saveVaultEntry(mkDef('cloudflare-oauth'), {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-old' },
clientInfo: { client_id: 'old-client' },
});
await saveVaultEntry(mkDef('cloudflare-newer-client-only'), {
clientInfo: { client_id: 'newer-client' },
});
await expect(loadVaultEntry(mkDef('cloudflare'))).resolves.toMatchObject({
serverName: 'cloudflare',
tokens: { access_token: 'old-token' },
clientInfo: { client_id: 'old-client' },
});
});
it('does not inherit unrelated same-url OAuth vault credentials', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-unrelated-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
await saveVaultEntry(mkDef('cloudflare-other'), {
tokens: { access_token: 'other-token', token_type: 'Bearer', refresh_token: 'refresh-other' },
clientInfo: { client_id: 'other-client' },
});
await expect(loadVaultEntry(mkDef('cloudflare'))).resolves.toBeUndefined();
});
it('does not add same-url client info to an exact token-only vault entry', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-token-only-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(currentDefinition, {
tokens: { access_token: 'current-token', token_type: 'Bearer', refresh_token: 'refresh-current' },
});
await saveVaultEntry(mkDef('cloudflare-other'), {
tokens: { access_token: 'other-token', token_type: 'Bearer', refresh_token: 'refresh-other' },
clientInfo: { client_id: 'other-client' },
});
const entry = await loadVaultEntry(currentDefinition);
expect(entry?.tokens?.access_token).toBe('current-token');
expect(entry?.clientInfo).toBeUndefined();
});
it('does not materialize inherited client info when exact tokens already exist', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-token-save-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(currentDefinition, {
tokens: { access_token: 'current-token', token_type: 'Bearer', refresh_token: 'refresh-current' },
});
await saveVaultEntry(mkDef('cloudflare-oauth'), {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-old' },
clientInfo: { client_id: 'old-client' },
});
await saveVaultEntry(currentDefinition, {
tokens: { access_token: 'new-current-token', token_type: 'Bearer', refresh_token: 'refresh-new-current' },
});
const entry = await loadVaultEntry(currentDefinition);
expect(entry?.tokens?.access_token).toBe('new-current-token');
expect(entry?.clientInfo).toBeUndefined();
});
it('clears inherited same-url OAuth vault credentials', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-clear-inherited-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-123' },
clientInfo: { client_id: 'old-client' },
});
await expect(loadVaultEntry(currentDefinition)).resolves.toMatchObject({
tokens: { access_token: 'old-token' },
clientInfo: { client_id: 'old-client' },
});
await clearVaultEntry(currentDefinition, 'all');
await expect(loadVaultEntry(currentDefinition)).resolves.toBeUndefined();
await expect(loadVaultEntry(oldDefinition)).resolves.toBeUndefined();
});
it('keeps inherited OAuth client info reachable after token-only invalidation', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-clear-inherited-tokens-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-123' },
clientInfo: { client_id: 'old-client' },
});
await clearVaultEntry(currentDefinition, 'tokens');
const entry = await loadVaultEntry(currentDefinition);
expect(entry?.tokens).toBeUndefined();
expect(entry?.clientInfo).toEqual(expect.objectContaining({ client_id: 'old-client' }));
});
it('clears legacy renamed credentials blocked by an exact client mismatch', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-clear-blocked-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const oldDefinition = mkDef('cloudflare-oauth');
const currentDefinition = mkDef('cloudflare');
await saveVaultEntry(oldDefinition, {
tokens: { access_token: 'old-token', token_type: 'Bearer', refresh_token: 'refresh-123' },
clientInfo: { client_id: 'old-client' },
});
await saveVaultEntry(currentDefinition, {
tokens: { access_token: 'current-token', token_type: 'Bearer', refresh_token: 'refresh-456' },
clientInfo: { client_id: 'current-client' },
});
await clearVaultEntry(currentDefinition, 'all');
await expect(loadVaultEntry(currentDefinition)).resolves.toBeUndefined();
await expect(loadVaultEntry(oldDefinition)).resolves.toBeUndefined();
});
it('does not create a vault file when clearing a missing vault entry', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-clear-'));
tempRoots.push(tmp);
@ -544,33 +188,6 @@ describe('oauth persistence', () => {
await expect(fs.access(`${vaultPath}.lock`)).rejects.toThrow();
});
it('skips malformed unrelated vault entries during same-url fallback scans', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-malformed-entry-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const definition = mkDef('cloudflare');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
await fs.writeFile(
vaultPath,
JSON.stringify({
version: 1,
entries: {
bad: null,
malformed: { serverName: 'cloudflare-oauth', serverUrl: 'https://example.com/mcp' },
},
}),
'utf8'
);
await expect(loadVaultEntry(definition)).resolves.toBeUndefined();
await expect(saveVaultEntry(definition, { state: 'ok' })).resolves.toBeUndefined();
await expect(clearVaultEntry(definition, 'all')).resolves.toBeUndefined();
});
it.runIf(process.platform !== 'win32')('surfaces unreadable vault files', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-unreadable-'));
tempRoots.push(tmp);
@ -726,7 +343,7 @@ describe('oauth persistence', () => {
expect(options).not.toHaveProperty('resource');
});
it('clears cached OAuth tokens when silent refresh fails permanently', async () => {
it('keeps the original cached OAuth token when silent refresh fails', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-fail-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
@ -746,146 +363,9 @@ describe('oauth persistence', () => {
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockRejectedValue(
Object.assign(new Error('Refresh token expired'), { errorCode: 'invalid_grant' })
);
authMocks.refreshAuthorization.mockRejectedValue(new Error('invalid_grant'));
const definition = mkDef('refresh-fail-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBeUndefined();
const persisted = (await readJsonFile(path.join(cacheDir, 'tokens.json'))) as
| { access_token?: string; refresh_token?: string }
| undefined;
expect(persisted).toBeUndefined();
await expect(readJsonFile(path.join(cacheDir, 'client.json'))).resolves.toEqual({ client_id: 'client-123' });
});
it('keeps newer cached OAuth tokens when a concurrent refresh wins first', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-race-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
const definition = mkDef('refresh-race-service', cacheDir);
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-old',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockImplementation(async () => {
const persistence = await buildOAuthPersistence(definition);
await persistence.saveTokens({
access_token: 'fresh-token',
token_type: 'Bearer',
refresh_token: 'refresh-old',
expires_in: 3600,
});
throw Object.assign(new Error('Refresh token expired'), { errorCode: 'invalid_grant' });
});
await expect(readCachedAccessToken(definition)).resolves.toBe('fresh-token');
await expect(readJsonFile(path.join(cacheDir, 'tokens.json'))).resolves.toEqual(
expect.objectContaining({ access_token: 'fresh-token', refresh_token: 'refresh-old' })
);
});
it('clears migrated legacy OAuth tokens when silent refresh fails permanently', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-legacy-refresh-fail-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const definition = mkDef('legacy-refresh-fail-service');
const legacyDir = path.join(tmp, '.mcporter', definition.name);
await fs.mkdir(legacyDir, { recursive: true });
await fs.writeFile(
path.join(legacyDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(legacyDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockRejectedValue(
Object.assign(new Error('Refresh token expired'), { errorCode: 'invalid_grant' })
);
await expect(readCachedAccessToken(definition)).resolves.toBeUndefined();
await expect(readJsonFile(path.join(legacyDir, 'tokens.json'))).resolves.toBeUndefined();
await expect(readJsonFile(path.join(legacyDir, 'client.json'))).resolves.toEqual({ client_id: 'client-123' });
authMocks.refreshAuthorization.mockClear();
await expect(readCachedAccessToken(definition)).resolves.toBeUndefined();
expect(authMocks.refreshAuthorization).not.toHaveBeenCalled();
});
it('clears cached OAuth client registration when refresh reports an invalid client', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-invalid-client-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'stale-client' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockRejectedValue(
Object.assign(new Error('Client ID mismatch'), { errorCode: 'invalid_client' })
);
const definition = mkDef('refresh-invalid-client-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBeUndefined();
await expect(readJsonFile(path.join(cacheDir, 'tokens.json'))).resolves.toBeUndefined();
await expect(readJsonFile(path.join(cacheDir, 'client.json'))).resolves.toBeUndefined();
});
it('keeps cached OAuth tokens when silent refresh fails transiently', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-transient-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockRejectedValue(new Error('network timeout'));
const definition = mkDef('refresh-transient-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBe('expired-token');
const persisted = (await readJsonFile(path.join(cacheDir, 'tokens.json'))) as
@ -899,37 +379,6 @@ describe('oauth persistence', () => {
);
});
it('keeps cached OAuth tokens when discovery fails with an invalid-client-like message', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-discovery-message-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockRejectedValue(new Error('invalid_client certificate chain'));
const definition = mkDef('discovery-message-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBe('expired-token');
await expect(readJsonFile(path.join(cacheDir, 'tokens.json'))).resolves.toEqual(
expect.objectContaining({ access_token: 'expired-token' })
);
await expect(readJsonFile(path.join(cacheDir, 'client.json'))).resolves.toEqual({ client_id: 'client-123' });
expect(authMocks.refreshAuthorization).not.toHaveBeenCalled();
});
it('refreshes explicit refreshable bearer tokens through the configured token endpoint', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bearer-refresh-'));
tempRoots.push(tmp);

Some files were not shown because too many files have changed in this diff Show More