Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe87142d89 | ||
|
|
782e028abe | ||
|
|
2a9b353b21 | ||
|
|
f02bef36d2 | ||
|
|
7491ed5a85 | ||
|
|
8beee8764f | ||
|
|
c1b58296db | ||
|
|
6f3f42ca42 | ||
|
|
53747cac63 | ||
|
|
4037f0a064 | ||
|
|
37391ce70b | ||
|
|
023314cf31 | ||
|
|
f2f67b4a38 | ||
|
|
870df28717 | ||
|
|
c9325a6a4a | ||
|
|
4813cdfe7a | ||
|
|
3e27b64021 | ||
|
|
8f74252a4d | ||
|
|
0fb13581fb | ||
|
|
2c04671b92 | ||
|
|
14ff39a59b | ||
|
|
68b228943c | ||
|
|
f37a642a80 | ||
|
|
2bf7a5eab2 | ||
|
|
56be50f763 | ||
|
|
815016a008 | ||
|
|
b86eec0b7f | ||
|
|
fb3f041339 | ||
|
|
f4f209317f | ||
|
|
552fcb1f60 | ||
|
|
49dc62b9ee | ||
|
|
1c5e96483e | ||
|
|
0c36a6d3f8 | ||
|
|
94e65ba057 | ||
|
|
67e3f5250f | ||
|
|
82b19535d8 | ||
|
|
9ec79f2b80 | ||
|
|
348483ea9f | ||
|
|
de7c811271 | ||
|
|
a1201d1955 | ||
|
|
31bbaa804f | ||
|
|
3ca4b5bae8 | ||
|
|
e6e9675519 | ||
|
|
ccfaa2f4f0 | ||
|
|
86e19f4413 | ||
|
|
1948ba7bef | ||
|
|
524e0a2d2f | ||
|
|
b8909e7cc0 | ||
|
|
90e8d00f12 | ||
|
|
1e6ce66d22 | ||
|
|
8c63bbe81e | ||
|
|
ae3b83cecb | ||
|
|
cbd84fd6d2 | ||
|
|
5ec589698f |
711
.agents/skills/crabbox/SKILL.md
Normal file
711
.agents/skills/crabbox/SKILL.md
Normal file
@ -0,0 +1,711 @@
|
||||
---
|
||||
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.
|
||||
53
.crabbox.yaml
Normal file
53
.crabbox.yaml
Normal file
@ -0,0 +1,53 @@
|
||||
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
Normal file
7
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# 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
|
||||
5
.github/actionlint.yaml
vendored
Normal file
5
.github/actionlint.yaml
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- crabbox
|
||||
- openclaw
|
||||
- mcporter
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@ -21,10 +21,10 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@ -48,6 +48,11 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --version
|
||||
- run: pnpm check
|
||||
if: matrix.os != 'macos-15'
|
||||
|
||||
- name: Check without type-aware oxlint
|
||||
if: matrix.os == 'macos-15'
|
||||
run: pnpm format:check && pnpm typecheck
|
||||
|
||||
- name: Verify generated schema is committed
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
|
||||
126
.github/workflows/crabbox-hydrate.yml
vendored
Normal file
126
.github/workflows/crabbox-hydrate.yml
vendored
Normal file
@ -0,0 +1,126 @@
|
||||
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
|
||||
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@ -119,3 +119,18 @@ Edit guidance: keep the actual tool list inside this `<tools></tools>` block so
|
||||
|
||||
- Live Deepwiki tests are opt-in; run with `MCP_LIVE_TESTS=1 ./runner pnpm exec vitest run tests/live/deepwiki-live.test.ts` when you need real endpoint coverage.
|
||||
- The skipped OAuth-promotion case in `tests/runtime-transport.test.ts` can be validated by temporarily unskipping it (Vitest does not support `--runInBand`). Remove any temporary helper files after running.
|
||||
|
||||
# Triage Scale
|
||||
|
||||
- Default pace: take time; read more code; prefer high-certainty answers over fast guesses.
|
||||
- Default fix style: prefer the clean refactor/fix boundary over a tiny shim when it reduces future bugs without much added complexity.
|
||||
- Autonomy yes: bug fixes with repro/root cause, bounded performance wins, small CLI/UI/UX polish, docs fixes, tests for these.
|
||||
- Ask first: new features/commands, broad behavior changes, new config/API surface, dependencies/build tooling, architecture shifts, unclear product calls.
|
||||
- Vision: if the repo has `VISION.md`, read it before triage; use it to decide what is automatic vs needs discussion.
|
||||
- PR/issue work: one ticket at a time. Ask whether the PR is the best option; push back or make a better PR when cleaner.
|
||||
- Research: read adjacent code deeply; use web/official docs when behavior, APIs, or dependencies are uncertain.
|
||||
- Verification: add focused regression tests; run full green gate; end-to-end/live test whenever possible.
|
||||
- Live credentials: if a live test needs access, look for exact keys via `$one-password`; if unavailable, stop and ask for help before fixing/landing.
|
||||
- No unverifiable autonomous fixes: if you cannot prove the fix live or with equivalent local proof, ask before proceeding.
|
||||
- Review: use `$codex-review` before push/land for non-trivial code; keep going until no accepted/actionable findings remain.
|
||||
- Landing: update PR/description as needed, push branch or create PR, watch CI to green, then land. After land, checkout `main`, pull `--ff-only`, verify clean.
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@ -1,5 +1,57 @@
|
||||
# mcporter Changelog
|
||||
|
||||
## [0.12.1] - 2026-06-18
|
||||
|
||||
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
|
||||
|
||||
### Config
|
||||
|
||||
- Skip imported server entries with unresolvable editor-specific environment placeholders, and allow later valid duplicates to take effect without relaxing validation for local config. (PR #209, thanks @Loveacup)
|
||||
|
||||
### OAuth
|
||||
|
||||
- Treat corrupt cached OAuth tokens and client metadata as missing so connections can re-authenticate, while keeping corrupt callback state data fail-closed. (Issue #207, thanks @KrasimirKralev)
|
||||
|
||||
### Tooling / Dependencies
|
||||
|
||||
- Refresh development dependencies and security overrides, including Vite, esbuild, and Hono.
|
||||
|
||||
## [0.12.0] - 2026-06-10
|
||||
|
||||
### OAuth
|
||||
|
||||
- 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)
|
||||
|
||||
## [0.11.2] - 2026-05-21
|
||||
|
||||
### CLI
|
||||
|
||||
- Add `mcporter list --status`, `--exit-code`, and `--quiet` for concise server health checks without introducing a separate health command.
|
||||
- Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc)
|
||||
- Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett)
|
||||
- Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff)
|
||||
- Add the documented top-level `daemonIdleTimeoutMs` config to shut down inactive keep-alive daemons. (Issue #174, thanks @jarek083)
|
||||
|
||||
## [0.11.1] - 2026-05-14
|
||||
|
||||
### CLI
|
||||
|
||||
15
README.md
15
README.md
@ -21,6 +21,7 @@ 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).
|
||||
|
||||
@ -60,6 +61,7 @@ npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz
|
||||
```
|
||||
|
||||
- Add `--json` to emit a machine-readable summary with per-server statuses (auth/offline/http/error counts) and, for single-server runs, the full tool schema payload.
|
||||
- Add `--status` for a concise single-server status check without tool docs, `--exit-code` to fail when any checked server is unhealthy, or `--quiet` for silent health gates.
|
||||
- Add `--verbose` to show every config source that registered the server name (primary first), both in text and JSON list output.
|
||||
|
||||
You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. Until you persist that definition, you still need to repeat the same URL/stdio flags for `mcporter call`—the printed slug only becomes reusable once you merge it into a config via `--persist` or `mcporter config add` (use `--scope home|project` to pick the write target). Follow up with `mcporter auth https://…` (or the same flag set) to finish OAuth without editing config. Full details live in [docs/adhoc.md](docs/adhoc.md).
|
||||
@ -141,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q
|
||||
```bash
|
||||
npx mcporter call chrome-devtools.take_snapshot
|
||||
npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")'
|
||||
npx mcporter call linear.create_comment issueId=LNR-123 body=@comment.md
|
||||
npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
|
||||
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
|
||||
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
||||
@ -161,8 +164,10 @@ 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.
|
||||
- `--output json/raw` (on `mcporter call`) -- when a connection fails, MCPorter prints the usual colorized hint and also emits a structured `{ server, tool, issue }` envelope so scripts can handle auth/offline/http errors programmatically.
|
||||
- `--json` (on `mcporter auth`) -- emit the same structured connection envelope whenever OAuth/transport setup fails, instead of throwing an error. With `--no-browser`, it emits auth-start JSON containing `authorizationUrl` and `redirectUrl`.
|
||||
- `--no-browser` / `--browser none` (on `mcporter auth` or `mcporter config login`) -- suppress browser launch and print the OAuth authorization URL for headless workflows; `MCPORTER_OAUTH_NO_BROWSER=1` / `true` / `yes` enables the same behavior.
|
||||
@ -196,7 +201,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`.
|
||||
- `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.
|
||||
- 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
|
||||
@ -251,7 +256,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.
|
||||
`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.
|
||||
|
||||
## Compose Automations with the Runtime
|
||||
|
||||
@ -418,7 +423,7 @@ npx mcporter config add notion https://mcp.notion.com/mcp --auth oauth
|
||||
npx mcporter auth notion
|
||||
```
|
||||
|
||||
On headless hosts, use `npx mcporter auth notion --no-browser` to print the authorization URL instead of launching the platform browser. Treat the printed URL as sensitive operational output. If you open it on another machine, make sure the printed `redirectUrl` callback port is reachable through a loopback-only tunnel or a configured `oauthRedirectUrl`.
|
||||
On headless hosts, use `npx mcporter auth notion --no-browser` to print the authorization URL instead of launching the platform browser. Treat the printed URL as sensitive operational output. Keep the `mcporter auth` process alive until the browser redirects back to the printed `redirectUrl`; process managers that exit or clean up the command after capturing stdout can kill the loopback callback listener before OAuth completes. Run the command from a persistent terminal session, `tmux`, or a supervised background process such as `nohup`, and if you open the URL on another machine, make sure the callback port is reachable through a loopback-only tunnel or a configured `oauthRedirectUrl`.
|
||||
|
||||
Providers that do not support dynamic client registration can use a pre-registered app:
|
||||
|
||||
@ -487,7 +492,7 @@ mcporter reads exactly one primary config per run. The lookup order is:
|
||||
1. The path you pass via `--config` (or programmatic `configPath`).
|
||||
2. The `MCPORTER_CONFIG` environment variable (set it in your shell to apply everywhere).
|
||||
3. `<root>/config/mcporter.json` inside the current project.
|
||||
4. `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, otherwise `~/.mcporter/mcporter.json[c]`, if the project file is missing.
|
||||
4. `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, falling back to `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists and the project file is missing.
|
||||
|
||||
All `mcporter config …` mutations write back to whichever file was selected by that order. To manage a system-wide config explicitly, point the CLI at it:
|
||||
|
||||
@ -497,7 +502,7 @@ mcporter config --config ~/.mcporter/mcporter.json add global-server https://api
|
||||
|
||||
Set `MCPORTER_CONFIG=~/.mcporter/mcporter.json` in your shell profile when you want that file to be the default everywhere (handy for `npx mcporter …` runs).
|
||||
|
||||
mcporter honors XDG Base Directory env vars for its own files when those vars are explicitly set: `XDG_CONFIG_HOME` for home configs, `XDG_DATA_HOME` for the OAuth vault, `XDG_CACHE_HOME` for schema caches, and `XDG_STATE_HOME` for daemon/runtime state. If the matching XDG var is unset or relative, mcporter keeps the legacy `~/.mcporter` path. Existing explicit overrides still win.
|
||||
mcporter honors XDG Base Directory env vars for its own files when those vars are explicitly set: `XDG_CONFIG_HOME` for home configs, `XDG_DATA_HOME` for the OAuth vault, `XDG_CACHE_HOME` for schema caches, and `XDG_STATE_HOME` for daemon/runtime state. If the matching XDG var is unset or relative, mcporter keeps the legacy `~/.mcporter` path. Config discovery is XDG-first but still probes `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists, which keeps embedders from hiding the user registry when they set `XDG_CONFIG_HOME` for another tool. Existing explicit overrides still win.
|
||||
|
||||
### Tool Filtering
|
||||
|
||||
|
||||
32
VISION.md
Normal file
32
VISION.md
Normal file
@ -0,0 +1,32 @@
|
||||
# MCPorter Vision
|
||||
|
||||
MCPorter should make any commonly supported MCP server usable from TypeScript, scripts, generated CLIs, and agent workflows with minimal setup.
|
||||
|
||||
## Product Goal
|
||||
|
||||
If an MCP server works in common MCP clients, it should be practical to use through MCPorter too. That includes local stdio servers, hosted HTTP/SSE servers, OAuth-protected providers, imported client configs, and ad-hoc endpoints.
|
||||
|
||||
MCPorter should stay small enough to understand, reliable enough for automation, and clear enough that failures tell the user what to fix next.
|
||||
|
||||
## What Good Work Looks Like
|
||||
|
||||
- Compatibility fixes for commonly used MCP servers, transports, schemas, auth flows, and client config formats.
|
||||
- Bug fixes with a clear reproduction, root cause, and verification path.
|
||||
- Performance work that improves startup, listing, calling, generated CLIs, daemon behavior, or repeated tool use without adding much complexity.
|
||||
- Small UI/UX improvements to CLI output, errors, help text, docs, and generated artifacts.
|
||||
- Refactors that make the correct fix cleaner, easier to test, or easier to maintain.
|
||||
- Tests and live/manual verification for behavior that can realistically be exercised.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Localization.
|
||||
- Major new product areas that are not about making MCP servers easier to discover, call, generate, type, host, or debug through MCPorter.
|
||||
- Broad features that make the product harder to reason about without a strong compatibility or reliability payoff.
|
||||
- Complex provider-specific flows when a small generic MCP/auth/transport improvement would solve the same class of problem.
|
||||
- Cosmetic churn, large rewrites, or dependency/tooling swaps without a concrete user-facing benefit.
|
||||
|
||||
## Triage Rule
|
||||
|
||||
Autonomous work is appropriate when it improves compatibility, correctness, performance, small CLI UX, docs, tests, or maintainability within this vision and can be verified end to end.
|
||||
|
||||
Ask first when the work changes product direction, adds a major feature, increases complexity substantially, needs unavailable live credentials, or cannot be verified with confidence.
|
||||
@ -62,7 +62,7 @@ The CLI still avoids surprise prompts during `mcporter list`; the upgrade happen
|
||||
|
||||
- OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions.
|
||||
- `mcporter auth` accepts the same `--http-url/--stdio` flags (and even bare URLs), so you can immediately re-run `mcporter auth https://…` after a 401 without touching a config file.
|
||||
- Use `mcporter auth <url> --no-browser` for human-in-the-loop headless OAuth. Text mode writes only the authorization URL to stdout; `--json --no-browser` writes `authorizationUrl` plus `redirectUrl` as JSON. Keep that URL out of durable CI logs and support bundles.
|
||||
- Use `mcporter auth <url> --no-browser` for human-in-the-loop headless OAuth. Text mode writes only the authorization URL to stdout; `--json --no-browser` writes `authorizationUrl` plus `redirectUrl` as JSON. Keep that URL out of durable CI logs and support bundles. The `mcporter auth` process must keep running until the browser redirects to `redirectUrl`; process managers that capture stdout and then tear down the command can kill the local callback listener before tokens are saved. Use a persistent terminal, `tmux`, or a supervised background process such as `nohup` when completing OAuth outside an interactive shell.
|
||||
- When opening the URL on a different machine, remember that loopback redirect URLs point at the browser machine unless an SSH tunnel forwards the callback port back to the mcporter process. Use `oauthRedirectUrl` when you need a predictable callback port.
|
||||
- Nothing is written to disk unless you pass `--persist /path/to/config.json`. When set, we merge the generated definition into that file (creating it if necessary) so future runs can rely on the standard config pipeline. Ad-hoc HTTP headers are persisted with the entry, so placeholders such as `--header 'Authorization=$env:MY_TOKEN'` keep working through the normal config header resolver.
|
||||
|
||||
|
||||
@ -71,6 +71,7 @@ Key details:
|
||||
- `--key value`, `--key=value`, `key=value`, `key:value`, `key: value`, and `key:=value` all map to the same named-argument handling, so you can type whichever feels most natural for your shell. Long flag keys convert kebab-case to camelCase (`--save-to-drafts true` becomes `saveToDrafts: true`). The `:=` form is accepted as a compatibility alias for `=`.
|
||||
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
|
||||
- `--args -` and `--json -` read a JSON object from stdin.
|
||||
- Named flag-style values can read exact UTF-8 text from a file with `key=@path` or `--key @path`. Paths resolve from the current working directory, file contents remain strings without coercion, and `key=@@literal` produces the literal value `@literal`. Function-call strings such as `body: "@literal"` remain literal.
|
||||
- Bare string values supplied via long flags wrap into one-item arrays when the tool schema declares that field as an array.
|
||||
- Numeric-looking `key=value` arguments are restored to their original string spelling when the tool schema declares that parameter as a string, which keeps timestamp-like IDs such as Slack `thread_ts=1234567890.123456` intact.
|
||||
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
|
||||
|
||||
@ -21,6 +21,10 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- Add `--brief` or `--signatures` with a server or `server.tool` target to keep
|
||||
the server header/instructions and print compact signatures without doc
|
||||
comments, examples, or schemas.
|
||||
- Add `--status` with a server target to print only the concise status row
|
||||
instead of full tool docs.
|
||||
- Add `--exit-code` to make the command exit 1 when any checked server is
|
||||
unhealthy, or `--quiet` to suppress output and imply `--exit-code`.
|
||||
- Hidden alias: `list-tools` (kept for muscle memory; not advertised in help output).
|
||||
- Hidden ad-hoc flag aliases: `--sse` for `--http-url`, `--insecure` for `--allow-http` (for plain HTTP testing).
|
||||
- Flags:
|
||||
@ -29,7 +33,13 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- `--signatures` – alias for `--brief`.
|
||||
- `--all-parameters` – include every optional parameter in the signature.
|
||||
- `--schema` – pretty-print the JSON schema for each tool.
|
||||
- `--status` – check server status only; cannot be combined with `--brief`,
|
||||
`--schema`, or `--all-parameters`.
|
||||
- `--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>`
|
||||
|
||||
@ -43,7 +53,10 @@ 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]`
|
||||
|
||||
@ -55,6 +68,8 @@ 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>]`
|
||||
|
||||
@ -63,14 +78,17 @@ 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`, bound to
|
||||
`127.0.0.1` by default.
|
||||
- `--http <port>` – serve MCP Streamable HTTP on `/mcp` and
|
||||
`/mcp/<server>`, 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.
|
||||
|
||||
@ -67,7 +67,7 @@ mcporter now merges home and project config files by default so global servers s
|
||||
1. If you pass `--config <file>` (or set `--config` programmatically), only that file is used—no merging.
|
||||
2. If `MCPORTER_CONFIG` is set, only that file is used—no merging.
|
||||
3. Otherwise, mcporter loads both of these layers (when present):
|
||||
- `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, otherwise `~/.mcporter/mcporter.json[c]`
|
||||
- `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, falling back to `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists
|
||||
- `<root>/config/mcporter.json`
|
||||
Entries from the project file override entries with the same name from the home file. Each layer still pulls in its own imports before merging.
|
||||
|
||||
@ -82,7 +82,7 @@ mcporter honors XDG Base Directory env vars for its own paths when they are expl
|
||||
| cache | `XDG_CACHE_HOME` | `$XDG_CACHE_HOME/mcporter/<server>/schema.json` | `~/.mcporter/...` |
|
||||
| state | `XDG_STATE_HOME` | `$XDG_STATE_HOME/mcporter/daemon/...` | `~/.mcporter/daemon` |
|
||||
|
||||
Unset, empty, or relative XDG vars fall back to `~/.mcporter` for backwards compatibility. Explicit overrides still win: `--config`/`MCPORTER_CONFIG` for config files, `tokenCacheDir` for per-server OAuth/schema cache directories, and `MCPORTER_DAEMON_DIR` for daemon files.
|
||||
Unset, empty, or relative XDG vars fall back to `~/.mcporter` for backwards compatibility. For config files only, an absolute `XDG_CONFIG_HOME` is XDG-first but still probes `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists, so embedders that sandbox unrelated tools with `XDG_CONFIG_HOME` do not accidentally hide the user's registry. Explicit overrides still win: `--config`/`MCPORTER_CONFIG` for config files, `tokenCacheDir` for per-server OAuth/schema cache directories, and `MCPORTER_DAEMON_DIR` for daemon files.
|
||||
|
||||
## Discovery & Precedence
|
||||
|
||||
@ -241,6 +241,8 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
|
||||
mcporter normalizes headers to include `Accept: application/json, text/event-stream` automatically, matching the runtime’s streaming expectations.
|
||||
String-valued config fields support `${VAR}` and `${VAR:-fallback}` placeholders. Secret-bearing `headers`, `env`, and bearer-token placeholders are preserved in `config get`/`config list` output and resolved only when the transport runs; `*Env` fields name environment variables and are not expanded.
|
||||
|
||||
Top-level `daemonIdleTimeoutMs` (or `daemon_idle_timeout_ms`) shuts down the keep-alive daemon after that many milliseconds without daemon requests. Per-server `lifecycle.idleTimeoutMs` still controls when individual keep-alive transports are closed.
|
||||
|
||||
### Refreshable Bearer Tokens
|
||||
|
||||
Use `auth: "refreshable_bearer"` when you already seeded OAuth tokens with `mcporter vault set <server>` or `tokenCacheDir`, and the server should receive only a fresh bearer token at runtime. HTTP servers get `Authorization: Bearer <token>` when no authorization header is already configured. STDIO servers require `refresh.accessTokenEnv`; mcporter refreshes before spawning the process and injects that env var with the raw access token.
|
||||
|
||||
@ -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`). 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` or CloudBase device authentication). 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 (initially `chrome-devtools`, `mobile-mcp`, `playwright`) so existing configs benefit immediately; users can opt out per server.
|
||||
- Ship a hardcoded allowlist (`chrome-devtools`, `mobile-mcp`, `playwright`, `cloudbase`) so existing configs benefit immediately; users can opt out per server.
|
||||
|
||||
## CLI Surface
|
||||
|
||||
@ -49,7 +49,7 @@ read_when:
|
||||
- **Auto start:** First call requiring the daemon triggers a lightweight bootstrap (fork/exec via `child_process.spawn` inside the CLI). We ensure the original command waits for the socket to become available (with a short timeout).
|
||||
- **macOS Bun binaries:** Homebrew/Bun-compiled binaries wrap the detached child launch with `nohup` so the background daemon survives the parent CLI exit on macOS 26.
|
||||
- **Auto restart:** The client shim treats `ECONNREFUSED`/broken pipe as a signal that the daemon died. It retries once by re-launching the daemon before surfacing the error.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A global `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A top-level config `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Logging:** Daemon writes structured logs under the daemon runtime directory plus per-server logs for STDIO stderr so users can debug crashing servers.
|
||||
|
||||
## Agent Isolation
|
||||
|
||||
@ -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 --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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@ -16,6 +16,7 @@ This file tracks limitations that users regularly run into. Most of these requir
|
||||
- Ask Supabase to accept the MCP scope or publish their scope list.
|
||||
- GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilot’s backend expects pre-registered client credentials. Configure `oauthClientId`/`oauthClientSecretEnv` only if the provider gives you a usable OAuth app; otherwise use their supported client or token/header workaround.
|
||||
- Some hosted servers reject dynamic client registration before returning any authorization URL. mcporter now fails those flows immediately instead of waiting for a browser callback that cannot arrive. If the provider supports a pre-registered OAuth app, configure `oauthClientId`, `oauthClientSecretEnv`, and the required `oauthTokenEndpointAuthMethod`; otherwise use the provider's supported client or token/header workaround.
|
||||
- `mcporter auth <server> --no-browser` still starts a loopback callback server and must stay alive until the browser redirects back. Process managers that run commands in short-lived process groups can print the authorization URL and then reap the process tree, leaving no listener on the callback port and no saved tokens. Run headless OAuth from a persistent terminal, `tmux`, or `nohup`/a supervisor, and use a configured `oauthRedirectUrl` or loopback tunnel when the browser runs elsewhere.
|
||||
|
||||
## Output schemas missing/buggy on many servers
|
||||
|
||||
|
||||
@ -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.
|
||||
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.
|
||||
|
||||
## Debug + Support Docs
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ This walkthrough assumes you already have an MCP server configured in Cursor, Cl
|
||||
npx mcporter list
|
||||
```
|
||||
|
||||
You get one row per server with auth status, transport type, and tool count. Add `--json` for machine output, or `--verbose` to see which config files registered each server.
|
||||
You get one row per server with auth status, transport type, and tool count. Add `--json` for machine output, `--quiet` for a silent health gate, or `--verbose` to see which config files registered each server.
|
||||
|
||||
## 2. Inspect a single server
|
||||
|
||||
@ -26,6 +26,7 @@ Single-server output reads like a TypeScript header file: dimmed `/** … */` do
|
||||
- `--all-parameters` — show every optional parameter inline.
|
||||
- `--schema` — pretty-print the JSON schema for each tool.
|
||||
- `--json` — machine-readable schema payload.
|
||||
- `--status` — concise status only, without tool docs.
|
||||
|
||||
`mcporter list shadcn.io/api/mcp.getComponents` works too — bare URLs (with or without a `.tool` suffix or scheme) auto-resolve.
|
||||
|
||||
|
||||
52
docs/record-replay.md
Normal file
52
docs/record-replay.md
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
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.
|
||||
@ -30,6 +30,7 @@ mcporter call context7.resolve-library-id libraryName: value
|
||||
|
||||
- Use `--flag value` when you prefer long-form CLI syntax.
|
||||
- Mixed forms are fine: `mcporter call linear.create_issue --team ENG title=value due: tomorrow`.
|
||||
- Use `body=@comment.md` (or `--body @comment.md`) to read an exact UTF-8 string from a file; use `body=@@literal` when the value itself starts with `@`.
|
||||
- `--args '{"title":"Bug"}'` still ingests JSON payloads directly.
|
||||
- Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`.
|
||||
|
||||
|
||||
170
examples/headless-pooling-demo.ts
Normal file
170
examples/headless-pooling-demo.ts
Normal file
@ -0,0 +1,170 @@
|
||||
#!/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);
|
||||
});
|
||||
@ -359,6 +359,18 @@
|
||||
},
|
||||
"description": "Map of server names to their configurations"
|
||||
},
|
||||
"daemonIdleTimeoutMs": {
|
||||
"description": "Idle timeout in milliseconds before shutting down an inactive daemon",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"daemon_idle_timeout_ms": {
|
||||
"description": "Idle timeout in milliseconds before shutting down an inactive daemon",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"imports": {
|
||||
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
||||
"type": "array",
|
||||
|
||||
51
package.json
51
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcporter",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.1",
|
||||
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@ -61,36 +61,42 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"acorn": "^8.16.0",
|
||||
"commander": "^14.0.3",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"acorn": "^8.17.0",
|
||||
"commander": "^15.0.0",
|
||||
"es-toolkit": "^1.48.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"ora": "^9.4.0",
|
||||
"rolldown": "1.0.0-rc.18",
|
||||
"ora": "^9.4.1",
|
||||
"rolldown": "1.1.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/estree": "^1.0.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260503.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"bun-types": "^1.3.13",
|
||||
"@types/node": "^26.0.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260623.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"bun-types": "^1.3.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"express": "^5.2.1",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"oxfmt": "^0.56.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
"vite": "8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"devEngines": {
|
||||
"runtime": [
|
||||
@ -103,12 +109,5 @@
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"body-parser": "2.2.1",
|
||||
"ip-address": "10.1.1",
|
||||
"vite": "8.0.10"
|
||||
}
|
||||
}
|
||||
"packageManager": "pnpm@10.33.2"
|
||||
}
|
||||
|
||||
1521
pnpm-lock.yaml
generated
1521
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,9 @@
|
||||
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
|
||||
|
||||
@ -74,16 +74,26 @@ export function metadataPathForArtifact(artifactPath: string): string {
|
||||
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
|
||||
// inspect command and falling back to legacy sidecar files.
|
||||
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||
let embeddedError: unknown;
|
||||
try {
|
||||
return await readMetadataFromCli(artifactPath);
|
||||
} catch (error) {
|
||||
embeddedError = error;
|
||||
}
|
||||
|
||||
const legacyPath = metadataPathForArtifact(artifactPath);
|
||||
try {
|
||||
const buffer = await fs.readFile(legacyPath, 'utf8');
|
||||
return JSON.parse(buffer) as CliArtifactMetadata;
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT') && embeddedError) {
|
||||
throw embeddedError;
|
||||
}
|
||||
if (!isErrno(error, 'ENOENT')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return await readMetadataFromCli(artifactPath);
|
||||
throw embeddedError;
|
||||
}
|
||||
|
||||
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||
|
||||
178
src/cli.ts
178
src/cli.ts
@ -4,6 +4,7 @@ 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';
|
||||
@ -154,6 +155,28 @@ 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(
|
||||
@ -197,14 +220,17 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
import('./lifecycle.js'),
|
||||
]);
|
||||
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
|
||||
const keepAliveServers = new Set(
|
||||
baseRuntime
|
||||
.getDefinitions()
|
||||
.filter(isKeepAliveServer)
|
||||
.map((entry) => entry.name)
|
||||
);
|
||||
const recordReplayModeActive = isRecordReplayModeActive();
|
||||
const keepAliveServers = recordReplayModeActive
|
||||
? new Set<string>()
|
||||
: new Set(
|
||||
baseRuntime
|
||||
.getDefinitions()
|
||||
.filter(isKeepAliveServer)
|
||||
.map((entry) => entry.name)
|
||||
);
|
||||
const daemonClient =
|
||||
keepAliveServers.size > 0
|
||||
!recordReplayModeActive && keepAliveServers.size > 0
|
||||
? new DaemonClient({
|
||||
configPath: configResolution.path,
|
||||
configExplicit: configResolution.explicit,
|
||||
@ -213,15 +239,16 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
: null;
|
||||
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
|
||||
|
||||
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
||||
if (inference.kind === 'abort') {
|
||||
process.exitCode = inference.exitCode;
|
||||
return;
|
||||
}
|
||||
const resolvedCommand = inference.command;
|
||||
const resolvedArgs = inference.args;
|
||||
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
||||
if (inference.kind === 'abort') {
|
||||
process.exitCode = inference.exitCode;
|
||||
return;
|
||||
}
|
||||
const resolvedCommand = inference.command;
|
||||
const resolvedArgs = inference.args;
|
||||
|
||||
if (resolvedCommand === 'list') {
|
||||
if (consumeHelpTokens(resolvedArgs)) {
|
||||
const { printListHelp } = await import('./cli/list-command.js');
|
||||
@ -281,46 +308,69 @@ 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 {
|
||||
const closeStart = Date.now();
|
||||
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();
|
||||
if (DEBUG_HANG) {
|
||||
logInfo('[debug] beginning runtime.close()');
|
||||
dumpActiveHandles('before runtime.close');
|
||||
const duration = Date.now() - closeStart;
|
||||
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
|
||||
dumpActiveHandles('after runtime.close');
|
||||
}
|
||||
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);
|
||||
} 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(() => {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, FORCE_EXIT_GRACE_MS);
|
||||
}
|
||||
};
|
||||
if (DEBUG_HANG) {
|
||||
dumpActiveHandles('after terminateChildProcesses');
|
||||
scheduleForcedExit();
|
||||
} else {
|
||||
setImmediate(scheduleForcedExit);
|
||||
}
|
||||
}
|
||||
printHelp(`Unknown command '${resolvedCommand}'.`);
|
||||
process.exit(1);
|
||||
if (closeError) {
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapperArgsBeforeSeparator(args: readonly string[]): string[] {
|
||||
const separatorIndex = args.indexOf('--');
|
||||
return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
// main parses CLI flags and dispatches to list/call commands.
|
||||
@ -360,6 +410,9 @@ 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;
|
||||
@ -427,6 +480,7 @@ 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);
|
||||
@ -454,6 +508,8 @@ function isExplicitNonCallCommand(command: string): boolean {
|
||||
command === 'resources' ||
|
||||
command === 'daemon' ||
|
||||
command === 'serve' ||
|
||||
command === 'record' ||
|
||||
command === 'replay' ||
|
||||
command === 'config' ||
|
||||
command === 'emit-ts' ||
|
||||
command === 'generate-cli' ||
|
||||
@ -529,6 +585,8 @@ 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({
|
||||
@ -536,9 +594,27 @@ 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.`);
|
||||
},
|
||||
|
||||
@ -49,7 +49,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
|
||||
};
|
||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
||||
const name = slugify(spec.name ?? canonical ?? inferNameFromUrl(url));
|
||||
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromUrl(url));
|
||||
const lifecycle = resolveLifecycle(name, undefined, command);
|
||||
const definition: ServerDefinition = {
|
||||
name,
|
||||
@ -84,7 +84,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
cwd,
|
||||
};
|
||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
||||
const name = slugify(spec.name ?? canonical ?? inferNameFromCommand(parts));
|
||||
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromCommand(parts));
|
||||
const lifecycle = resolveLifecycle(name, undefined, command);
|
||||
const definition: ServerDefinition = {
|
||||
name,
|
||||
@ -206,6 +206,14 @@ function slugify(value: string): string {
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function normalizeEphemeralName(value: string): string {
|
||||
const name = slugify(value);
|
||||
if (!name) {
|
||||
throw new Error('Ad-hoc server name must contain at least one letter or digit.');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function splitCommandLine(input: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
|
||||
@ -25,6 +25,7 @@ export interface CallArgsParseResult {
|
||||
tailLog: boolean;
|
||||
output: OutputFormat;
|
||||
timeoutMs?: number;
|
||||
disableOAuth?: boolean;
|
||||
ephemeral?: EphemeralServerSpec;
|
||||
rawStrings?: boolean;
|
||||
saveImagesDir?: string;
|
||||
@ -59,6 +60,7 @@ const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
||||
'--tool': handleToolFlag,
|
||||
'--timeout': handleTimeoutFlag,
|
||||
'--tail-log': handleTailLogFlag,
|
||||
'--no-oauth': handleDisableOAuthFlag,
|
||||
'--save-images': handleSaveImagesFlag,
|
||||
'--yes': handleNoopFlag,
|
||||
'--raw-strings': handleRawStringsFlag,
|
||||
@ -191,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
||||
continue;
|
||||
}
|
||||
index += parsed.consumed;
|
||||
const value = coerceValue(parsed.rawValue, state.coercionMode);
|
||||
const { value, schemaValue } = resolveNamedArgumentValue(parsed.rawValue, state.coercionMode);
|
||||
if (parsed.key === 'tool' && !result.tool) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error("Argument 'tool' must be a string value.");
|
||||
@ -208,7 +210,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
||||
}
|
||||
if (state.coercionMode === 'default' && typeof value === 'number') {
|
||||
result.schemaStringCoercionCandidates ??= {};
|
||||
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
|
||||
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
|
||||
}
|
||||
result.args[parsed.key] = value;
|
||||
}
|
||||
@ -256,6 +258,11 @@ 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,
|
||||
@ -320,18 +327,53 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
|
||||
eqIndex === -1
|
||||
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
||||
: body.slice(eqIndex + 1);
|
||||
const value = coerceValue(rawValue, context.state.coercionMode);
|
||||
const { value, schemaValue } = resolveNamedArgumentValue(rawValue, context.state.coercionMode);
|
||||
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
||||
context.result.schemaStringCoercionCandidates ??= {};
|
||||
context.result.schemaStringCoercionCandidates[key] = rawValue;
|
||||
context.result.schemaStringCoercionCandidates[key] = schemaValue;
|
||||
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
||||
context.result.schemaArrayCoercionCandidates ??= {};
|
||||
context.result.schemaArrayCoercionCandidates[key] = rawValue;
|
||||
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
|
||||
}
|
||||
context.result.args[key] = value;
|
||||
return context.index + (eqIndex === -1 ? 2 : 1);
|
||||
}
|
||||
|
||||
function resolveNamedArgumentValue(
|
||||
rawValue: string,
|
||||
coercionMode: CoercionMode
|
||||
): { value: unknown; schemaValue: string } {
|
||||
if (rawValue.startsWith('@@')) {
|
||||
const literal = rawValue.slice(1);
|
||||
return { value: literal, schemaValue: literal };
|
||||
}
|
||||
if (rawValue.length > 0 && rawValue.trim() === '') {
|
||||
return { value: rawValue, schemaValue: rawValue };
|
||||
}
|
||||
if (!rawValue.startsWith('@')) {
|
||||
return { value: coerceValue(rawValue, coercionMode), schemaValue: rawValue };
|
||||
}
|
||||
|
||||
const filePath = rawValue.slice(1);
|
||||
if (!filePath) {
|
||||
throw new CliUsageError("Argument file reference '@' requires a path. Use '@@' for a literal leading '@'.");
|
||||
}
|
||||
|
||||
let contents: Buffer;
|
||||
try {
|
||||
contents = fs.readFileSync(filePath);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CliUsageError(`Unable to read argument file '${filePath}': ${detail}`);
|
||||
}
|
||||
try {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(contents);
|
||||
return { value: text, schemaValue: text };
|
||||
} catch {
|
||||
throw new CliUsageError(`Argument file '${filePath}' is not valid UTF-8 text.`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
||||
if (!rawKey || rawKey.startsWith('-')) {
|
||||
return '';
|
||||
|
||||
@ -39,6 +39,7 @@ interface PreparedCallRequest extends ResolvedCallTarget {
|
||||
parsed: CallArgsParseResult;
|
||||
hydratedArgs: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
disableOAuth?: boolean;
|
||||
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
||||
}
|
||||
|
||||
@ -66,12 +67,19 @@ 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)) {
|
||||
if (await maybeDescribeServer(runtime, server, tool, parsed.output, parsed.disableOAuth)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
|
||||
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
|
||||
const hydratedArgs = await hydratePositionalArguments(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
parsed.args,
|
||||
parsed.positionalArgs,
|
||||
parsed.disableOAuth
|
||||
);
|
||||
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
|
||||
runtime,
|
||||
server,
|
||||
@ -79,9 +87,18 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
||||
hydratedArgs,
|
||||
parsed.schemaStringCoercionCandidates,
|
||||
parsed.schemaArrayCoercionCandidates,
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
parsed.disableOAuth
|
||||
);
|
||||
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs, ephemeralTarget };
|
||||
return {
|
||||
parsed,
|
||||
server,
|
||||
tool,
|
||||
hydratedArgs: schemaAwareArgs,
|
||||
timeoutMs,
|
||||
disableOAuth: parsed.disableOAuth,
|
||||
ephemeralTarget,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeParsedCallArguments(
|
||||
@ -145,7 +162,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);
|
||||
tool = await inferSingleToolName(runtime, server, parsed.disableOAuth);
|
||||
if (!tool) {
|
||||
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
||||
}
|
||||
@ -165,7 +182,8 @@ async function invokePreparedCall(
|
||||
prepared.tool,
|
||||
prepared.hydratedArgs,
|
||||
prepared.timeoutMs,
|
||||
prepared.parsed.output
|
||||
prepared.parsed.output,
|
||||
prepared.disableOAuth
|
||||
);
|
||||
} catch (error) {
|
||||
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
||||
@ -224,11 +242,15 @@ async function maybeDescribeServer(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
tool: string,
|
||||
outputFormat: OutputFormat
|
||||
outputFormat: OutputFormat,
|
||||
disableOAuth: boolean | undefined
|
||||
): 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');
|
||||
}
|
||||
@ -239,7 +261,9 @@ async function maybeDescribeServer(
|
||||
if (tool !== 'help') {
|
||||
return false;
|
||||
}
|
||||
const tools = await runtime.listTools(server, { includeSchema: false, autoAuthorize: false }).catch(() => undefined);
|
||||
const tools = await runtime
|
||||
.listTools(server, { includeSchema: false, autoAuthorize: false, disableOAuth })
|
||||
.catch(() => undefined);
|
||||
if (!tools) {
|
||||
return false;
|
||||
}
|
||||
@ -249,6 +273,9 @@ 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');
|
||||
}
|
||||
@ -296,7 +323,8 @@ async function enforceSchemaAwareArgumentTypes(
|
||||
args: Record<string, unknown>,
|
||||
stringCandidates: Record<string, string> | undefined,
|
||||
arrayCandidates: Record<string, string> | undefined,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (
|
||||
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
||||
@ -305,9 +333,10 @@ async function enforceSchemaAwareArgumentTypes(
|
||||
return args;
|
||||
}
|
||||
|
||||
const tools = await withTimeout(loadToolMetadata(runtime, server, { includeSchema: true }), timeoutMs).catch(
|
||||
() => undefined
|
||||
);
|
||||
const tools = await withTimeout(
|
||||
loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }),
|
||||
timeoutMs
|
||||
).catch(() => undefined);
|
||||
if (!tools) {
|
||||
return args;
|
||||
}
|
||||
@ -389,14 +418,15 @@ async function hydratePositionalArguments(
|
||||
server: string,
|
||||
tool: string,
|
||||
namedArgs: Record<string, unknown>,
|
||||
positionalArgs: unknown[] | undefined
|
||||
positionalArgs: unknown[] | undefined,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!positionalArgs || positionalArgs.length === 0) {
|
||||
return namedArgs;
|
||||
}
|
||||
// We need the schema order to know which field each positional argument maps to; pull the
|
||||
// tool list with schemas instead of guessing locally so optional/required order stays correct.
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true }).catch(() => undefined);
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }).catch(() => undefined);
|
||||
if (!tools) {
|
||||
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
||||
}
|
||||
@ -436,9 +466,10 @@ type ToolResolution = IdentifierResolution;
|
||||
|
||||
async function inferSingleToolName(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string
|
||||
server: string,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<string | undefined> {
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth });
|
||||
if (tools.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
@ -456,10 +487,11 @@ async function invokeWithAutoCorrection(
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat
|
||||
outputFormat: OutputFormat,
|
||||
disableOAuth: boolean | undefined
|
||||
): 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);
|
||||
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true, disableOAuth);
|
||||
}
|
||||
|
||||
async function attemptCall(
|
||||
@ -469,14 +501,24 @@ async function attemptCall(
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat,
|
||||
allowCorrection: boolean
|
||||
allowCorrection: boolean,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||
try {
|
||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs);
|
||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs, disableOAuth }), timeoutMs);
|
||||
if (allowCorrection && isErrorCallResult(result)) {
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, result);
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, result, disableOAuth);
|
||||
if (resolution) {
|
||||
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
|
||||
const retry = await maybeRetryResolvedTool(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
args,
|
||||
timeoutMs,
|
||||
outputFormat,
|
||||
resolution,
|
||||
disableOAuth
|
||||
);
|
||||
if (retry) {
|
||||
return retry;
|
||||
}
|
||||
@ -497,13 +539,22 @@ async function attemptCall(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, error);
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, error, disableOAuth);
|
||||
if (!resolution) {
|
||||
maybeReportConnectionIssue(server, tool, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
|
||||
const retry = await maybeRetryResolvedTool(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
args,
|
||||
timeoutMs,
|
||||
outputFormat,
|
||||
resolution,
|
||||
disableOAuth
|
||||
);
|
||||
if (!retry) {
|
||||
throw error;
|
||||
}
|
||||
@ -518,7 +569,8 @@ async function maybeRetryResolvedTool(
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat,
|
||||
resolution: ToolResolution
|
||||
resolution: ToolResolution,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
entity: 'tool',
|
||||
@ -536,14 +588,15 @@ 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);
|
||||
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false, disableOAuth);
|
||||
}
|
||||
|
||||
async function maybeResolveToolName(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
attemptedTool: string,
|
||||
error: unknown
|
||||
error: unknown,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<ToolResolution | undefined> {
|
||||
const missingName = extractMissingToolFromError(error);
|
||||
if (!missingName) {
|
||||
@ -555,7 +608,7 @@ async function maybeResolveToolName(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false }).catch(() => undefined);
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth }).catch(() => undefined);
|
||||
if (!tools) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const CALL_HELP_ARGUMENT_LINES = [
|
||||
' key=value / key:value Flag-style named arguments.',
|
||||
' key=@path Read a UTF-8 string value from a file; use @@ for a literal @.',
|
||||
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
|
||||
' --args <json> Provide a JSON object payload.',
|
||||
' positional values Accepted when schema order is known.',
|
||||
@ -10,6 +11,7 @@ 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.',
|
||||
@ -31,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
|
||||
|
||||
export const CALL_HELP_EXAMPLE_LINES = [
|
||||
' mcporter call linear.list_issues team=ENG limit:5',
|
||||
' mcporter call linear.create_comment body=@comment.md',
|
||||
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
|
||||
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
||||
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',
|
||||
|
||||
@ -2,6 +2,7 @@ import { resolveConfigPath } from '../config/path-discovery.js';
|
||||
import { parseLogLevel } from '../logging.js';
|
||||
import { extractFlags } from './flag-utils.js';
|
||||
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
|
||||
import { parsePositiveInteger } from './timeouts.js';
|
||||
|
||||
export interface GlobalCliContext {
|
||||
readonly globalFlags: Record<string, string | undefined>;
|
||||
@ -29,8 +30,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
|
||||
|
||||
let oauthTimeoutOverride: number | undefined;
|
||||
if (globalFlags['--oauth-timeout']) {
|
||||
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
|
||||
if (parsed === undefined) {
|
||||
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
|
||||
return { exit: true, code: 1 };
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
|
||||
}
|
||||
}
|
||||
if (flags.copy) {
|
||||
const lockPath = resolveConfigPath(options.loadOptions.configPath, rootDir).path;
|
||||
const lockPath = resolveImportCopyTarget(options.loadOptions.configPath, rootDir);
|
||||
let configPath = lockPath;
|
||||
await withFileLock(lockPath, async () => {
|
||||
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
|
||||
@ -71,6 +71,13 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportCopyTarget(configPath: string | undefined, rootDir: string): string {
|
||||
if (configPath || process.env.MCPORTER_CONFIG) {
|
||||
return resolveConfigPath(configPath, rootDir).path;
|
||||
}
|
||||
return path.resolve(rootDir, 'config', 'mcporter.json');
|
||||
}
|
||||
|
||||
function extractImportFlags(args: string[]): ImportFlags {
|
||||
const flags: ImportFlags = { format: 'text' };
|
||||
let index = 0;
|
||||
|
||||
@ -259,7 +259,7 @@ async function writeFile(targetPath: string, contents: string): Promise<void> {
|
||||
function computeImportPath(fromPath: string, typesPath: string): string {
|
||||
const fromDir = path.dirname(fromPath);
|
||||
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
|
||||
const withoutExt = relative.replace(/\.[^.]+$/, '');
|
||||
const withoutExt = relative.endsWith('.d.ts') ? relative.slice(0, -5) : relative.replace(/\.[^.]+$/, '');
|
||||
if (withoutExt.startsWith('.')) {
|
||||
return withoutExt;
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ 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;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { parsePositiveInteger } from '../timeouts.js';
|
||||
|
||||
export interface GeneratorCommonFlags {
|
||||
runtime?: 'node' | 'bun';
|
||||
timeout?: number;
|
||||
@ -31,8 +33,8 @@ export function extractGeneratorFlags(args: string[], options: ExtractOptions =
|
||||
if (!raw) {
|
||||
throw new Error("Flag '--timeout' requires a value.");
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const parsed = parsePositiveInteger(raw);
|
||||
if (parsed === undefined) {
|
||||
throw new Error('--timeout must be a positive integer.');
|
||||
}
|
||||
result.timeout = parsed;
|
||||
|
||||
32
src/cli/generate/stable-json.ts
Normal file
32
src/cli/generate/stable-json.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export function stableJsonStringify(value: unknown, space?: number): string {
|
||||
const json = JSON.stringify(sortJsonValue(value), undefined, space);
|
||||
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;
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { markExecutable } from './fs-helpers.js';
|
||||
import { renderEmbeddedHelpSource } from './template-help.js';
|
||||
import type { GeneratedOption, ToolMetadata } from './tools.js';
|
||||
import { buildEmbeddedSchemaMap } from './tools.js';
|
||||
import { stableJsonStringify } from './stable-json.js';
|
||||
|
||||
export interface TemplateInput {
|
||||
outputPath?: string;
|
||||
@ -75,7 +76,10 @@ export function renderTemplate({
|
||||
"import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';",
|
||||
"import { createCallResult } from 'mcporter';",
|
||||
].join('\n');
|
||||
const embedded = JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value), 2);
|
||||
const embedded = stableJsonStringify(
|
||||
JSON.parse(JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value))),
|
||||
2
|
||||
);
|
||||
const relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
|
||||
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/openclaw/mcporter`;
|
||||
const toolDocs = tools.map((tool) => ({
|
||||
@ -97,6 +101,7 @@ export function renderTemplate({
|
||||
tool: entry.tool,
|
||||
})
|
||||
);
|
||||
assertUniqueGeneratedCommandNames(renderedTools);
|
||||
const toolHelp = renderedTools.map((entry) => ({
|
||||
name: entry.commandName,
|
||||
description: entry.tool.tool.description ?? '',
|
||||
@ -104,15 +109,13 @@ export function renderTemplate({
|
||||
flags: entry.doc.flagUsage ?? '',
|
||||
}));
|
||||
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
|
||||
const toolHelpLiteral = JSON.stringify(toolHelp, undefined, 2);
|
||||
const embeddedSchemas = JSON.stringify(buildEmbeddedSchemaMap(tools), undefined, 2);
|
||||
const embeddedMetadata = JSON.stringify(metadata, undefined, 2);
|
||||
const toolHelpLiteral = stableJsonStringify(toolHelp, 2);
|
||||
const embeddedSchemas = stableJsonStringify(buildEmbeddedSchemaMap(tools), 2);
|
||||
const embeddedMetadata = stableJsonStringify(metadata, 2);
|
||||
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
|
||||
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
|
||||
const signatureMapLiteral = JSON.stringify(signatureMap, undefined, 2);
|
||||
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version} on ${
|
||||
metadata.generatedAt
|
||||
}. DO NOT EDIT.`;
|
||||
const signatureMapLiteral = stableJsonStringify(signatureMap, 2);
|
||||
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version}. DO NOT EDIT.`;
|
||||
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
|
||||
${generatedHeaderComment}
|
||||
${imports}
|
||||
@ -235,7 +238,7 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
|
||||
\t}
|
||||
\tconst values = value.split(',').map((entry) => entry.trim());
|
||||
\tif (itemType === 'number') {
|
||||
\t\treturn values.map((entry) => parseFloat(entry));
|
||||
\t\treturn values.map((entry) => parseFiniteNumber(entry));
|
||||
\t}
|
||||
\tif (itemType === 'boolean') {
|
||||
\t\treturn values.map((entry) => entry !== 'false');
|
||||
@ -243,6 +246,15 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
|
||||
\treturn values;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value: string): number {
|
||||
\tconst trimmed = value.trim();
|
||||
\tconst parsed = Number(trimmed);
|
||||
\tif (trimmed === '' || !Number.isFinite(parsed)) {
|
||||
\t\tthrow new Error('Expected a finite number.');
|
||||
\t}
|
||||
\treturn parsed;
|
||||
}
|
||||
|
||||
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
||||
\tconst base = { ...server } as Record<string, unknown>;
|
||||
\tif ((server.command as any).kind === 'http') {
|
||||
@ -460,7 +472,9 @@ export function renderToolCommand(
|
||||
({ option, camelCaseProp }) =>
|
||||
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
|
||||
)
|
||||
.join(', ')}].filter((entry) => entry.value === undefined).map((entry) => entry.flag);
|
||||
.join(
|
||||
', '
|
||||
)}].filter((entry) => entry.value === undefined || (typeof entry.value === 'string' && entry.value.trim() === '')).map((entry) => entry.flag);
|
||||
\t\t\tif (missingRequired.length > 0) {
|
||||
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
||||
\t\t\t}`
|
||||
@ -547,7 +561,7 @@ export const templateTestHelpers = { computeRelativeStdioCwd };
|
||||
function optionParser(option: GeneratedOption): string | undefined {
|
||||
switch (option.type) {
|
||||
case 'number':
|
||||
return '(value) => parseFloat(value)';
|
||||
return '(value) => parseFiniteNumber(value)';
|
||||
case 'boolean':
|
||||
return "(value) => value !== 'false'";
|
||||
case 'object':
|
||||
@ -568,3 +582,16 @@ function optionParser(option: GeneratedOption): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueGeneratedCommandNames(tools: Array<{ commandName: string; tool: ToolMetadata }>): void {
|
||||
const commands = new Map<string, string>();
|
||||
for (const entry of tools) {
|
||||
const previous = commands.get(entry.commandName);
|
||||
if (previous) {
|
||||
throw new Error(
|
||||
`Generated command name collision '${entry.commandName}' for tools '${previous}' and '${entry.tool.tool.name}'.`
|
||||
);
|
||||
}
|
||||
commands.set(entry.commandName, entry.tool.tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,9 +50,30 @@ 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) {
|
||||
for (const entry of tools.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name))) {
|
||||
if (entry.tool.inputSchema && typeof entry.tool.inputSchema === 'object') {
|
||||
result[entry.tool.name] = entry.tool.inputSchema;
|
||||
}
|
||||
|
||||
@ -72,6 +72,16 @@ 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>]',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -93,6 +93,9 @@ function parseInspectFlags(args: string[]): InspectFlags {
|
||||
if (!artifactPath) {
|
||||
throw new Error('Usage: mcporter inspect-cli <artifact> [--json]');
|
||||
}
|
||||
if (args.length > 0) {
|
||||
throw new Error(`Unexpected inspect-cli argument '${args[0]}'.`);
|
||||
}
|
||||
return { artifactPath, format };
|
||||
}
|
||||
|
||||
|
||||
@ -60,6 +60,9 @@ export async function handleList(
|
||||
const perServerTimeoutSeconds = Math.round(perServerTimeoutMs / 1000);
|
||||
|
||||
if (servers.length === 0) {
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
if (flags.format === 'json') {
|
||||
const payload = {
|
||||
mode: 'list',
|
||||
@ -73,17 +76,17 @@ export async function handleList(
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.format === 'text') {
|
||||
if (!flags.quiet && flags.format === 'text') {
|
||||
console.log(
|
||||
`mcporter ${MCPORTER_VERSION} — Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`
|
||||
);
|
||||
}
|
||||
const spinner =
|
||||
flags.format === 'text' && supportsSpinner
|
||||
!flags.quiet && flags.format === 'text' && supportsSpinner
|
||||
? ora(`Discovering ${servers.length} server(s)…`).start()
|
||||
: undefined;
|
||||
const renderedResults =
|
||||
flags.format === 'text'
|
||||
!flags.quiet && flags.format === 'text'
|
||||
? (Array.from({ length: servers.length }, () => undefined) as Array<
|
||||
ReturnType<typeof renderServerListRow> | undefined
|
||||
>)
|
||||
@ -95,28 +98,7 @@ export async function handleList(
|
||||
let completedCount = 0;
|
||||
|
||||
const tasks = servers.map((server, index) =>
|
||||
(async (): Promise<ListSummaryResult> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const tools = await withTimeout(
|
||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
|
||||
perServerTimeoutMs
|
||||
);
|
||||
return {
|
||||
server,
|
||||
status: 'ok' as const,
|
||||
tools,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
server,
|
||||
status: 'error' as const,
|
||||
error,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
})().then((result) => {
|
||||
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
|
||||
summaryResults[index] = result;
|
||||
if (renderedResults) {
|
||||
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
||||
@ -139,20 +121,25 @@ export async function handleList(
|
||||
);
|
||||
|
||||
await Promise.all(tasks);
|
||||
const jsonEntries = summaryResults.map((entry, index) => {
|
||||
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
|
||||
if (!serverDefinition) {
|
||||
throw new Error('Unable to resolve server definition for JSON output.');
|
||||
}
|
||||
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
|
||||
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
|
||||
includeSchemas: Boolean(flags.schema),
|
||||
includeSources: Boolean(flags.verbose || flags.includeSources),
|
||||
});
|
||||
});
|
||||
const counts = summarizeStatusCounts(jsonEntries);
|
||||
maybeSetListExitCode(jsonEntries, flags);
|
||||
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.format === 'json') {
|
||||
const jsonEntries = summaryResults.map((entry, index) => {
|
||||
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
|
||||
if (!serverDefinition) {
|
||||
throw new Error('Unable to resolve server definition for JSON output.');
|
||||
}
|
||||
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
|
||||
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
|
||||
includeSchemas: Boolean(flags.schema),
|
||||
includeSources: Boolean(flags.verbose || flags.includeSources),
|
||||
});
|
||||
});
|
||||
const counts = summarizeStatusCounts(jsonEntries);
|
||||
console.log(JSON.stringify({ mode: 'list', counts, servers: jsonEntries }, null, 2));
|
||||
return;
|
||||
}
|
||||
@ -160,21 +147,13 @@ export async function handleList(
|
||||
if (spinner) {
|
||||
spinner.stop();
|
||||
}
|
||||
const errorCounts = createEmptyStatusCounts();
|
||||
renderedResults?.forEach((entry) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const category = entry.category ?? 'error';
|
||||
errorCounts[category] = (errorCounts[category] ?? 0) + 1;
|
||||
});
|
||||
const okSummary = `${errorCounts.ok} healthy`;
|
||||
const okSummary = `${counts.ok} healthy`;
|
||||
const parts = [
|
||||
okSummary,
|
||||
...(errorCounts.auth > 0 ? [`${errorCounts.auth} auth required`] : []),
|
||||
...(errorCounts.offline > 0 ? [`${errorCounts.offline} offline`] : []),
|
||||
...(errorCounts.http > 0 ? [`${errorCounts.http} http errors`] : []),
|
||||
...(errorCounts.error > 0 ? [`${errorCounts.error} errors`] : []),
|
||||
...(counts.auth > 0 ? [`${counts.auth} auth required`] : []),
|
||||
...(counts.offline > 0 ? [`${counts.offline} offline`] : []),
|
||||
...(counts.http > 0 ? [`${counts.http} http errors`] : []),
|
||||
...(counts.error > 0 ? [`${counts.error} errors`] : []),
|
||||
];
|
||||
console.log(`✔ Listed ${servers.length} server${servers.length === 1 ? '' : 's'} (${parts.join('; ')}).`);
|
||||
return;
|
||||
@ -190,9 +169,13 @@ export async function handleList(
|
||||
requestedTool = selector.tool;
|
||||
}
|
||||
}
|
||||
if (flags.statusOnly && requestedTool) {
|
||||
throw new Error('--status cannot be used with a tool selector.');
|
||||
}
|
||||
|
||||
const resolved = resolveServerDefinition(runtime, target);
|
||||
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
|
||||
if (!resolved) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
target = resolved.name;
|
||||
@ -204,14 +187,119 @@ export async function handleList(
|
||||
: undefined;
|
||||
const transportSummary = formatTransportSummary(definition);
|
||||
const startedAt = Date.now();
|
||||
if (flags.format === 'json') {
|
||||
if (flags.statusOnly) {
|
||||
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
||||
try {
|
||||
const result = await checkListServer(runtime, definition, timeoutMs, flags.disableOAuth);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
|
||||
includeSchemas: false,
|
||||
includeSources: Boolean(flags.verbose || flags.includeSources),
|
||||
});
|
||||
maybeSetListExitCode([entry], flags);
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
if (flags.format === 'json') {
|
||||
console.log(
|
||||
JSON.stringify({ mode: 'list', counts: summarizeStatusCounts([entry]), servers: [entry] }, null, 2)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rendered = renderServerListRow(result, timeoutMs, { verbose: flags.verbose });
|
||||
console.log(rendered.line);
|
||||
console.log(
|
||||
`✔ Listed 1 server (${entry.status === 'ok' ? '1 healthy' : `0 healthy; 1 ${statusLabel(entry.status)}`}).`
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
if (previousStdioLogMode !== undefined) {
|
||||
setStdioLogMode(previousStdioLogMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
||||
try {
|
||||
if (flags.format === 'json') {
|
||||
try {
|
||||
const metadataEntries = filterToolMetadata(
|
||||
await withTimeout(
|
||||
loadToolMetadata(runtime, target, {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: flags.disableOAuth,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
requestedTool
|
||||
);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (requestedTool && metadataEntries.length === 0) {
|
||||
if (!flags.quiet) {
|
||||
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const instructions = await loadServerInstructions(runtime, target);
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: 'ok' as StatusCategory,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
instructions,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
tools: metadataEntries.map((entry) => ({
|
||||
name: entry.tool.name,
|
||||
description: entry.tool.description,
|
||||
inputSchema: entry.tool.inputSchema,
|
||||
outputSchema: entry.tool.outputSchema,
|
||||
options: entry.options,
|
||||
})),
|
||||
};
|
||||
if (!flags.quiet) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: advice.category,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
issue: advice.issue,
|
||||
authCommand: advice.authCommand,
|
||||
error: advice.summary,
|
||||
};
|
||||
if (!flags.quiet) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
|
||||
const metadataEntries = filterToolMetadata(
|
||||
await withTimeout(
|
||||
loadToolMetadata(runtime, target, {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: flags.disableOAuth,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
@ -220,96 +308,62 @@ export async function handleList(
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (requestedTool && metadataEntries.length === 0) {
|
||||
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
|
||||
if (!flags.quiet) {
|
||||
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
const instructions = await loadServerInstructions(runtime, target);
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: 'ok' as StatusCategory,
|
||||
const summaryLine = printSingleServerHeader(
|
||||
definition,
|
||||
metadataEntries.length,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
instructions,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
tools: metadataEntries.map((entry) => ({
|
||||
name: entry.tool.name,
|
||||
description: entry.tool.description,
|
||||
inputSchema: entry.tool.inputSchema,
|
||||
outputSchema: entry.tool.outputSchema,
|
||||
options: entry.options,
|
||||
})),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: advice.category,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
issue: advice.issue,
|
||||
authCommand: advice.authCommand,
|
||||
error: advice.summary,
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
|
||||
const metadataEntries = filterToolMetadata(
|
||||
await withTimeout(
|
||||
loadToolMetadata(runtime, target, {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
requestedTool
|
||||
);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (requestedTool && metadataEntries.length === 0) {
|
||||
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
|
||||
return;
|
||||
}
|
||||
const instructions = await loadServerInstructions(runtime, target);
|
||||
const summaryLine = printSingleServerHeader(
|
||||
definition,
|
||||
metadataEntries.length,
|
||||
durationMs,
|
||||
transportSummary,
|
||||
sourcePath,
|
||||
{
|
||||
printSummaryNow: false,
|
||||
instructions,
|
||||
transportSummary,
|
||||
sourcePath,
|
||||
{
|
||||
printSummaryNow: false,
|
||||
instructions,
|
||||
}
|
||||
);
|
||||
if (metadataEntries.length === 0) {
|
||||
console.log(' Tools: <none>');
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
);
|
||||
if (metadataEntries.length === 0) {
|
||||
console.log(' Tools: <none>');
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
if (flags.brief) {
|
||||
if (flags.brief) {
|
||||
let optionalOmitted = false;
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printBriefTool(definition, entry, flags.requiredOnly);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
if (flags.requiredOnly && optionalOmitted) {
|
||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||
console.log('');
|
||||
}
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
const examples: string[] = [];
|
||||
let optionalOmitted = false;
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printBriefTool(definition, entry, flags.requiredOnly);
|
||||
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
|
||||
examples.push(...detail.examples);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
const uniqueExamples = formatExampleBlock(examples);
|
||||
if (uniqueExamples.length > 0) {
|
||||
console.log(` ${dimText('Examples:')}`);
|
||||
for (const example of uniqueExamples) {
|
||||
console.log(` ${example}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
if (flags.requiredOnly && optionalOmitted) {
|
||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||
console.log('');
|
||||
@ -317,42 +371,85 @@ export async function handleList(
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
const examples: string[] = [];
|
||||
let optionalOmitted = false;
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
|
||||
examples.push(...detail.examples);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
const uniqueExamples = formatExampleBlock(examples);
|
||||
if (uniqueExamples.length > 0) {
|
||||
console.log(` ${dimText('Examples:')}`);
|
||||
for (const example of uniqueExamples) {
|
||||
console.log(` ${example}`);
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
maybeSetListExitCode([{ status: 'error' }], flags);
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
const durationMs = Date.now() - startedAt;
|
||||
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
|
||||
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
|
||||
console.warn(` Reason: ${message}`);
|
||||
if (advice.category === 'auth' && advice.authCommand) {
|
||||
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
if (flags.requiredOnly && optionalOmitted) {
|
||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||
console.log('');
|
||||
} finally {
|
||||
if (previousStdioLogMode !== undefined) {
|
||||
setStdioLogMode(previousStdioLogMode);
|
||||
}
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkListServer(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: ServerDefinition,
|
||||
timeoutMs: number,
|
||||
disableOAuth: boolean
|
||||
): Promise<ListSummaryResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const tools = await withTimeout(
|
||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true, disableOAuth }),
|
||||
timeoutMs
|
||||
);
|
||||
return {
|
||||
server,
|
||||
status: 'ok' as const,
|
||||
tools,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
|
||||
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
|
||||
console.warn(` Reason: ${message}`);
|
||||
if (advice.category === 'auth' && advice.authCommand) {
|
||||
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
|
||||
}
|
||||
return {
|
||||
server,
|
||||
status: 'error' as const,
|
||||
error,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSetListExitCode(
|
||||
entries: readonly { status: StatusCategory }[],
|
||||
flags: ReturnType<typeof extractListFlags>
|
||||
): void {
|
||||
if (!flags.exitCode) {
|
||||
return;
|
||||
}
|
||||
if (entries.some((entry) => entry.status !== 'ok')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: StatusCategory): string {
|
||||
switch (status) {
|
||||
case 'auth':
|
||||
return 'auth required';
|
||||
case 'offline':
|
||||
return 'offline';
|
||||
case 'http':
|
||||
return 'http error';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'ok':
|
||||
return 'healthy';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,13 +480,19 @@ export function printListHelp(): void {
|
||||
' --schema Show tool schemas when listing servers.',
|
||||
' --all-parameters Include optional parameters in tool docs.',
|
||||
' --json Emit a JSON summary instead of text.',
|
||||
' --status Check server status only, without tool docs.',
|
||||
' --exit-code Exit 1 when any checked server is unhealthy.',
|
||||
' --quiet Suppress output; implies --exit-code.',
|
||||
' --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',
|
||||
' mcporter list --quiet',
|
||||
' mcporter list linear --schema',
|
||||
' mcporter list linear --status --json',
|
||||
' mcporter list linear --brief',
|
||||
' mcporter list linear.list_issues --signatures',
|
||||
' mcporter list https://mcp.example.com/mcp',
|
||||
@ -400,7 +503,8 @@ export function printListHelp(): void {
|
||||
|
||||
function resolveServerDefinition(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
name: string
|
||||
name: string,
|
||||
options: { quiet?: boolean } = {}
|
||||
): { definition: ServerDefinition; name: string } | undefined {
|
||||
try {
|
||||
const definition = runtime.getDefinition(name);
|
||||
@ -411,7 +515,9 @@ function resolveServerDefinition(
|
||||
}
|
||||
const suggestion = suggestServerName(runtime, name);
|
||||
if (!suggestion) {
|
||||
console.error(error.message);
|
||||
if (!options.quiet) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
@ -420,13 +526,17 @@ function resolveServerDefinition(
|
||||
resolution: suggestion,
|
||||
});
|
||||
if (suggestion.kind === 'auto' && messages.auto) {
|
||||
console.log(dimText(messages.auto));
|
||||
return resolveServerDefinition(runtime, suggestion.value);
|
||||
if (!options.quiet) {
|
||||
console.log(dimText(messages.auto));
|
||||
}
|
||||
return resolveServerDefinition(runtime, suggestion.value, options);
|
||||
}
|
||||
if (messages.suggest) {
|
||||
if (!options.quiet && messages.suggest) {
|
||||
console.error(yellowText(messages.suggest));
|
||||
}
|
||||
console.error(error.message);
|
||||
if (!options.quiet) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,10 @@ export function extractListFlags(args: string[]): {
|
||||
verbose: boolean;
|
||||
includeSources: boolean;
|
||||
brief: boolean;
|
||||
quiet: boolean;
|
||||
exitCode: boolean;
|
||||
statusOnly: boolean;
|
||||
disableOAuth: boolean;
|
||||
} {
|
||||
let schema = false;
|
||||
let timeoutMs: number | undefined;
|
||||
@ -21,6 +25,10 @@ export function extractListFlags(args: string[]): {
|
||||
let verbose = false;
|
||||
let includeSources = false;
|
||||
let brief = false;
|
||||
let quiet = false;
|
||||
let exitCode = false;
|
||||
let statusOnly = false;
|
||||
let disableOAuth = false;
|
||||
const format = consumeOutputFormat(args, {
|
||||
defaultFormat: 'text',
|
||||
allowed: ['text', 'json'],
|
||||
@ -60,6 +68,27 @@ export function extractListFlags(args: string[]): {
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--quiet') {
|
||||
quiet = true;
|
||||
exitCode = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--exit-code') {
|
||||
exitCode = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--status') {
|
||||
statusOnly = true;
|
||||
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;
|
||||
@ -84,5 +113,33 @@ export function extractListFlags(args: string[]): {
|
||||
throw new Error(`--brief cannot be used with ${conflicts.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose, includeSources, brief };
|
||||
if (statusOnly) {
|
||||
const conflicts: string[] = [];
|
||||
if (brief) {
|
||||
conflicts.push('--brief');
|
||||
}
|
||||
if (schema) {
|
||||
conflicts.push('--schema');
|
||||
}
|
||||
if (!requiredOnly) {
|
||||
conflicts.push('--all-parameters');
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(`--status cannot be used with ${conflicts.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
schema,
|
||||
timeoutMs,
|
||||
requiredOnly,
|
||||
ephemeral,
|
||||
format,
|
||||
verbose,
|
||||
includeSources,
|
||||
brief,
|
||||
quiet,
|
||||
exitCode,
|
||||
statusOnly,
|
||||
disableOAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@ -266,5 +266,5 @@ function quoteCommandSegment(segment: string): string {
|
||||
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return JSON.stringify(segment);
|
||||
return `'${segment.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import type { CallResult } from '../result-utils.js';
|
||||
import { logWarn } from './logger-context.js';
|
||||
@ -33,17 +34,8 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
||||
return;
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
if (typeof result === 'string') {
|
||||
const idx = result.indexOf(':');
|
||||
if (idx !== -1) {
|
||||
const candidate = result.slice(idx + 1).trim();
|
||||
if (candidate) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result && typeof result === 'object') {
|
||||
const possibleKeys = ['logPath', 'logFile', 'logfile', 'path'];
|
||||
const possibleKeys = ['logPath', 'logFile', 'logfile'];
|
||||
for (const key of possibleKeys) {
|
||||
const value = (result as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string') {
|
||||
@ -53,6 +45,10 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
logWarn(`Refusing to tail non-absolute log path: ${candidate}`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) {
|
||||
logWarn(`Log path not found: ${candidate}`);
|
||||
continue;
|
||||
|
||||
150
src/cli/record-command.ts
Normal file
150
src/cli/record-command.ts
Normal file
@ -0,0 +1,150 @@
|
||||
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 };
|
||||
}
|
||||
46
src/cli/record-replay-env.ts
Normal file
46
src/cli/record-replay-env.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
84
src/cli/replay-command.ts
Normal file
84
src/cli/replay-command.ts
Normal file
@ -0,0 +1,84 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -13,6 +13,7 @@ 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]');
|
||||
@ -24,7 +25,14 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
const issue = analyzeConnectionError(error);
|
||||
if (output === 'json' || output === 'raw') {
|
||||
@ -39,6 +47,20 @@ 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(
|
||||
[
|
||||
@ -51,6 +73,7 @@ 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',
|
||||
|
||||
@ -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.
|
||||
--http <port> Serve MCP Streamable HTTP on /mcp and /mcp/<server>.
|
||||
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
const DEFAULT_LIST_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
||||
const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
|
||||
|
||||
export function parsePositiveInteger(raw: string | undefined): number | undefined {
|
||||
if (!raw || !POSITIVE_INTEGER_PATTERN.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
// parseTimeout reads timeout values from strings while honoring defaults.
|
||||
export function parseTimeout(raw: string | undefined, fallback: number): number {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
return parsePositiveInteger(raw) ?? fallback;
|
||||
}
|
||||
|
||||
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
|
||||
@ -58,8 +63,8 @@ export function consumeTimeoutFlag(
|
||||
if (!value) {
|
||||
throw new Error(missingValueMessage);
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const parsed = parsePositiveInteger(value);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
|
||||
}
|
||||
args.splice(index, 2);
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { ListToolsOptions, Runtime } from '../runtime.js';
|
||||
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
|
||||
import { buildToolMetadataList, type ToolMetadata } from './generate/tools.js';
|
||||
|
||||
interface LoadToolMetadataOptions {
|
||||
includeSchema?: boolean;
|
||||
autoAuthorize?: boolean;
|
||||
allowCachedAuth?: boolean;
|
||||
disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
||||
@ -12,8 +13,9 @@ const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>(
|
||||
function cacheKey(serverName: string, options: LoadToolMetadataOptions): string {
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
const allowCachedAuth = options.allowCachedAuth === true;
|
||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}`;
|
||||
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'}`;
|
||||
}
|
||||
|
||||
export async function loadToolMetadata(
|
||||
@ -33,13 +35,15 @@ export async function loadToolMetadata(
|
||||
}
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
const listOptions: ListToolsOptions =
|
||||
options.allowCachedAuth === undefined
|
||||
? { includeSchema, autoAuthorize }
|
||||
: { includeSchema, autoAuthorize, allowCachedAuth: options.allowCachedAuth };
|
||||
const listOptions: ListToolsOptions = {
|
||||
includeSchema,
|
||||
autoAuthorize,
|
||||
allowCachedAuth: options.allowCachedAuth ?? true,
|
||||
disableOAuth: options.disableOAuth,
|
||||
};
|
||||
const promise = runtime
|
||||
.listTools(serverName, listOptions)
|
||||
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
|
||||
.then((tools) => buildToolMetadataList(tools, { sort: false }))
|
||||
.catch((error) => {
|
||||
cache?.delete(key);
|
||||
throw error;
|
||||
|
||||
@ -121,12 +121,44 @@ function validateVaultPayload(value: unknown): VaultPayload {
|
||||
) {
|
||||
throw new CliUsageError("Vault payload 'clientInfo' must be an object.");
|
||||
}
|
||||
validateOAuthTokens(record.tokens as Record<string, unknown>);
|
||||
if (record.clientInfo !== undefined) {
|
||||
validateOAuthClientInfo(record.clientInfo as Record<string, unknown>);
|
||||
}
|
||||
return {
|
||||
tokens: record.tokens as OAuthTokens,
|
||||
...(record.clientInfo ? { clientInfo: record.clientInfo as OAuthClientInformationMixed } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOAuthTokens(tokens: Record<string, unknown>): void {
|
||||
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
|
||||
throw new CliUsageError('Vault payload tokens.access_token must be a non-empty string.');
|
||||
}
|
||||
if (typeof tokens.token_type !== 'string' || tokens.token_type.length === 0) {
|
||||
throw new CliUsageError('Vault payload tokens.token_type must be a non-empty string.');
|
||||
}
|
||||
for (const key of ['refresh_token', 'scope'] as const) {
|
||||
if (tokens[key] !== undefined && typeof tokens[key] !== 'string') {
|
||||
throw new CliUsageError(`Vault payload tokens.${key} must be a string.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
tokens.expires_in !== undefined &&
|
||||
(!Number.isFinite(tokens.expires_in) || typeof tokens.expires_in !== 'number')
|
||||
) {
|
||||
throw new CliUsageError('Vault payload tokens.expires_in must be a finite number.');
|
||||
}
|
||||
}
|
||||
|
||||
function validateOAuthClientInfo(clientInfo: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(clientInfo)) {
|
||||
if (value !== undefined && value !== null && typeof value !== 'string') {
|
||||
throw new CliUsageError(`Vault payload clientInfo.${key} must be a string.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printVaultHelp(): void {
|
||||
const lines = [
|
||||
'Usage: mcporter vault <set|clear> ...',
|
||||
|
||||
@ -175,6 +175,18 @@ export const RawEntrySchema = z
|
||||
export const RawConfigSchema = z
|
||||
.object({
|
||||
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
|
||||
daemonIdleTimeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
|
||||
daemon_idle_timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
|
||||
imports: z
|
||||
.array(ImportKindSchema)
|
||||
.optional()
|
||||
|
||||
@ -58,10 +58,16 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
||||
continue;
|
||||
}
|
||||
for (const [name, rawEntry] of entries) {
|
||||
const source: ServerSource = { kind: 'import', path: resolved, importKind };
|
||||
const baseDir = path.dirname(resolved);
|
||||
try {
|
||||
normalizeServerEntry(name, rawEntry, baseDir, source, [source]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (merged.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const source: ServerSource = { kind: 'import', path: resolved, importKind };
|
||||
const existing = merged.get(name);
|
||||
// Keep the first-seen source as canonical while tracking all alternates
|
||||
if (existing) {
|
||||
@ -70,7 +76,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
||||
}
|
||||
merged.set(name, {
|
||||
raw: rawEntry,
|
||||
baseDir: path.dirname(resolved),
|
||||
baseDir,
|
||||
source,
|
||||
sources: [source],
|
||||
});
|
||||
@ -99,12 +105,35 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
||||
|
||||
const servers: ServerDefinition[] = [];
|
||||
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
|
||||
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
|
||||
try {
|
||||
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
|
||||
} catch (error) {
|
||||
if (source.kind !== 'import') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
export interface DaemonConfig {
|
||||
readonly idleTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function loadDaemonConfig(options: LoadConfigOptions = {}): Promise<DaemonConfig> {
|
||||
const rootDir = options.rootDir ?? process.cwd();
|
||||
const layers = await loadConfigLayers(options, rootDir);
|
||||
let idleTimeoutMs: number | undefined;
|
||||
for (const layer of layers) {
|
||||
const raw = layer.config.daemonIdleTimeoutMs ?? layer.config.daemon_idle_timeout_ms;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
idleTimeoutMs = Math.trunc(raw);
|
||||
}
|
||||
}
|
||||
return { idleTimeoutMs };
|
||||
}
|
||||
|
||||
export async function loadRawConfig(
|
||||
options: LoadConfigOptions = {}
|
||||
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
|
||||
|
||||
@ -3,6 +3,7 @@ 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,
|
||||
@ -23,6 +24,7 @@ export interface DaemonClientOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_DAEMON_TIMEOUT_MS = 30_000;
|
||||
const MIN_DAEMON_STATUS_TIMEOUT_MS = 1_000;
|
||||
|
||||
export interface DaemonPaths {
|
||||
readonly key: string;
|
||||
@ -83,14 +85,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async status(): Promise<StatusResult | null> {
|
||||
try {
|
||||
return (await this.sendRequest<StatusResult>('status', {})) as StatusResult;
|
||||
} catch (error) {
|
||||
if (isTransportError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await this.readVerifiedStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
@ -105,7 +100,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private async invoke<T = unknown>(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise<T> {
|
||||
await this.ensureDaemon();
|
||||
await this.ensureDaemon(timeoutMs);
|
||||
try {
|
||||
return (await this.sendRequest<T>(method, params, timeoutMs)) as T;
|
||||
} catch (error) {
|
||||
@ -117,47 +112,87 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDaemon(): Promise<void> {
|
||||
const configState = await this.checkConfigState();
|
||||
private async ensureDaemon(timeoutMs?: number): Promise<void> {
|
||||
const statusTimeoutMs = resolveDaemonStatusTimeout(timeoutMs);
|
||||
const metadata = await readDaemonMetadata(this.metadataPath);
|
||||
const configState = await this.checkConfigState(metadata);
|
||||
if (configState === 'stale') {
|
||||
await this.stop().catch(() => {});
|
||||
await this.restartDaemon();
|
||||
await this.restartDaemon({ reason: 'stale-config', expectedPid: metadata?.pid });
|
||||
return;
|
||||
}
|
||||
if (configState === 'fresh') {
|
||||
return;
|
||||
if (await this.isResponsive(statusTimeoutMs)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.startDaemon();
|
||||
await this.waitForReady();
|
||||
await this.startDaemon({ preflightTimeoutMs: statusTimeoutMs });
|
||||
}
|
||||
|
||||
private async restartDaemon(): Promise<void> {
|
||||
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 startDaemon(): Promise<void> {
|
||||
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> {
|
||||
if (this.startingPromise) {
|
||||
await this.startingPromise;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
});
|
||||
this.startingPromise = withFileLock(this.metadataPath, async () => {
|
||||
await task();
|
||||
}).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) {
|
||||
@ -169,20 +204,31 @@ export class DaemonClient {
|
||||
throw new Error('Timeout while waiting for MCPorter daemon to start.');
|
||||
}
|
||||
|
||||
private async isResponsive(): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await this.sendRequest('status', {});
|
||||
return true;
|
||||
const status = (await this.sendRequest<StatusResult>('status', {}, timeoutMs)) as StatusResult;
|
||||
if (status.pid !== metadata.pid || status.socketPath !== metadata.socketPath) {
|
||||
return null;
|
||||
}
|
||||
return status;
|
||||
} catch (error) {
|
||||
if (isTransportError(error)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkConfigState(): Promise<DaemonConfigState> {
|
||||
const metadata = await readDaemonMetadata(this.metadataPath);
|
||||
private async checkConfigState(metadata?: DaemonMetadata | null): Promise<DaemonConfigState> {
|
||||
metadata ??= await readDaemonMetadata(this.metadataPath);
|
||||
if (!metadata) {
|
||||
return 'missing';
|
||||
}
|
||||
@ -290,6 +336,18 @@ 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;
|
||||
@ -305,6 +363,13 @@ 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);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { LoadConfigOptions } from '../config.js';
|
||||
import { listConfigLayerPaths } from '../config.js';
|
||||
|
||||
@ -19,5 +20,8 @@ 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;
|
||||
}
|
||||
|
||||
40
src/daemon/definition-hash.ts
Normal file
40
src/daemon/definition-hash.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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;
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import type { ServerDefinition } from '../config.js';
|
||||
import { writeJsonFile } from '../fs-json.js';
|
||||
import { loadDaemonConfig, type ServerDefinition } from '../config.js';
|
||||
import { readJsonFile, withFileLock, 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,
|
||||
@ -26,9 +28,11 @@ import type {
|
||||
} from './protocol.js';
|
||||
import {
|
||||
buildErrorResponse,
|
||||
daemonIdleWatcherInterval,
|
||||
ensureManaged,
|
||||
evictIdleServers,
|
||||
markActivity,
|
||||
shouldShutdownDaemonForIdle,
|
||||
type ServerActivity,
|
||||
} from './request-utils.js';
|
||||
|
||||
@ -48,11 +52,16 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
configPath: options.configExplicit ? options.configPath : undefined,
|
||||
rootDir: options.rootDir,
|
||||
});
|
||||
const daemonConfig = await loadDaemonConfig({
|
||||
configPath: options.configExplicit ? options.configPath : undefined,
|
||||
rootDir: options.rootDir,
|
||||
});
|
||||
const runtime = await createRuntime({
|
||||
configPath: options.configExplicit ? options.configPath : undefined,
|
||||
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.');
|
||||
}
|
||||
@ -77,7 +86,6 @@ 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);
|
||||
|
||||
@ -86,9 +94,37 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
activity.set(definition.name, { connected: false });
|
||||
}
|
||||
|
||||
const idleWatcher = setInterval(() => {
|
||||
void evictIdleServers(runtime, managedServers, activity);
|
||||
}, 30_000);
|
||||
let shuttingDown = false;
|
||||
let idleWatcher: NodeJS.Timeout | undefined;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
logEvent(logContext, 'Shutting down daemon host.');
|
||||
if (idleWatcher) {
|
||||
clearInterval(idleWatcher);
|
||||
}
|
||||
server.close();
|
||||
await runtime.close().catch(() => {});
|
||||
await disposeLogContext(logContext).catch(() => {});
|
||||
await cleanupArtifacts(options);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
let lastDaemonActivityAt = Date.now();
|
||||
let activeDaemonRequests = 0;
|
||||
idleWatcher = setInterval(() => {
|
||||
void (async () => {
|
||||
await evictIdleServers(runtime, managedServers, activity);
|
||||
if (
|
||||
shouldShutdownDaemonForIdle(lastDaemonActivityAt, Date.now(), daemonConfig.idleTimeoutMs, activeDaemonRequests)
|
||||
) {
|
||||
logEvent(logContext, 'Daemon idle timeout reached.');
|
||||
await shutdown();
|
||||
}
|
||||
})();
|
||||
}, daemonIdleWatcherInterval(daemonConfig.idleTimeoutMs));
|
||||
idleWatcher.unref();
|
||||
|
||||
logEvent(logContext, 'Daemon host started.');
|
||||
@ -115,6 +151,8 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
handled = true;
|
||||
lastDaemonActivityAt = Date.now();
|
||||
activeDaemonRequests += 1;
|
||||
void handleSocketRequest(
|
||||
trimmed,
|
||||
socket,
|
||||
@ -128,11 +166,15 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
startedAt,
|
||||
logPath: options.logPath ?? null,
|
||||
configMtimeMs,
|
||||
definitionHash,
|
||||
},
|
||||
logContext,
|
||||
shutdown,
|
||||
parsedRequest
|
||||
);
|
||||
).finally(() => {
|
||||
activeDaemonRequests -= 1;
|
||||
lastDaemonActivityAt = Date.now();
|
||||
});
|
||||
};
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
@ -149,44 +191,252 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
shuttingDown = true;
|
||||
logEvent(logContext, 'Shutting down daemon host.');
|
||||
clearInterval(idleWatcher);
|
||||
await prepareSocket(options.socketPath);
|
||||
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(() => {});
|
||||
await cleanupArtifacts(options);
|
||||
process.exit(0);
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@ -202,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
|
||||
await cleanupDaemonArtifactsIfOwned(options, process.pid);
|
||||
}
|
||||
|
||||
export async function cleanupDaemonArtifactsIfOwned(
|
||||
paths: Pick<DaemonHostOptions, 'metadataPath' | 'socketPath'>,
|
||||
ownerPid: number
|
||||
): Promise<void> {
|
||||
// A superseded daemon may finish shutting down after its replacement has
|
||||
// already rebound the same paths. Never let that old process unlink the
|
||||
// replacement daemon's live socket and metadata.
|
||||
const metadata = await readJsonFile<{ pid?: number; socketPath?: string }>(paths.metadataPath).catch(() => undefined);
|
||||
if (metadata?.pid !== ownerPid || metadata.socketPath !== paths.socketPath) {
|
||||
return;
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.unlink(options.socketPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.unlink(options.metadataPath);
|
||||
} catch {
|
||||
// ignore
|
||||
await fs.unlink(paths.socketPath).catch(() => {});
|
||||
}
|
||||
await fs.unlink(paths.metadataPath).catch(() => {});
|
||||
}
|
||||
|
||||
async function handleSocketRequest(
|
||||
@ -229,6 +485,7 @@ async function handleSocketRequest(
|
||||
socketPath: string;
|
||||
startedAt: number;
|
||||
logPath: string | null;
|
||||
definitionHash?: string;
|
||||
},
|
||||
logContext: LogContext,
|
||||
shutdown: () => Promise<void>,
|
||||
@ -252,6 +509,13 @@ 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,
|
||||
@ -264,6 +528,7 @@ async function processRequest(
|
||||
socketPath: string;
|
||||
startedAt: number;
|
||||
logPath: string | null;
|
||||
definitionHash?: string;
|
||||
},
|
||||
logContext: LogContext,
|
||||
preParsedRequest?: DaemonRequest
|
||||
@ -302,6 +567,7 @@ 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) {
|
||||
@ -319,6 +585,7 @@ 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}`);
|
||||
@ -326,7 +593,9 @@ async function processRequest(
|
||||
try {
|
||||
const result = await runtime.listTools(params.server, {
|
||||
includeSchema: params.includeSchema,
|
||||
autoAuthorize: params.autoAuthorize,
|
||||
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
|
||||
allowCachedAuth: params.allowCachedAuth ?? true,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
@ -349,7 +618,11 @@ async function processRequest(
|
||||
logEvent(logContext, `listResources start server=${params.server}`);
|
||||
}
|
||||
try {
|
||||
const result = await runtime.listResources(params.server, params.params);
|
||||
const result = await runtime.listResources(params.server, {
|
||||
...params.params,
|
||||
allowCachedAuth: params.allowCachedAuth,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
logEvent(logContext, `listResources success server=${params.server}`);
|
||||
@ -371,7 +644,10 @@ async function processRequest(
|
||||
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
|
||||
}
|
||||
try {
|
||||
const result = await runtime.readResource(params.server, params.uri);
|
||||
const result = await runtime.readResource(params.server, params.uri, {
|
||||
allowCachedAuth: params.allowCachedAuth,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
logEvent(logContext, `readResource success server=${params.server}`);
|
||||
@ -417,6 +693,7 @@ 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) => {
|
||||
@ -451,6 +728,16 @@ 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,
|
||||
@ -463,6 +750,7 @@ export async function __testProcessRequest(
|
||||
socketPath: string;
|
||||
startedAt: number;
|
||||
logPath: string | null;
|
||||
definitionHash?: string;
|
||||
},
|
||||
logContext: LogContext,
|
||||
preParsedRequest?: DaemonRequest
|
||||
|
||||
@ -28,22 +28,29 @@ export interface CallToolParams {
|
||||
readonly tool: string;
|
||||
readonly args?: Record<string, unknown>;
|
||||
readonly timeoutMs?: number;
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ListToolsParams {
|
||||
readonly server: string;
|
||||
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 {
|
||||
@ -59,6 +66,7 @@ export interface StatusResult {
|
||||
readonly path: string;
|
||||
readonly mtimeMs: number | null;
|
||||
}>;
|
||||
readonly definitionHash?: string;
|
||||
readonly socketPath: string;
|
||||
readonly logPath?: string;
|
||||
readonly servers: Array<{
|
||||
|
||||
@ -49,6 +49,27 @@ export async function evictIdleServers(
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShutdownDaemonForIdle(
|
||||
lastActivityAt: number,
|
||||
now: number,
|
||||
idleTimeoutMs: number | undefined,
|
||||
activeRequests = 0
|
||||
): boolean {
|
||||
return (
|
||||
activeRequests <= 0 &&
|
||||
typeof idleTimeoutMs === 'number' &&
|
||||
idleTimeoutMs > 0 &&
|
||||
now - lastActivityAt >= idleTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
export function daemonIdleWatcherInterval(idleTimeoutMs: number | undefined): number {
|
||||
if (!idleTimeoutMs) {
|
||||
return 30_000;
|
||||
}
|
||||
return Math.min(30_000, Math.max(100, Math.floor(idleTimeoutMs / 2)));
|
||||
}
|
||||
|
||||
export function buildErrorResponse(id: string, code: string, error?: unknown): DaemonResponse {
|
||||
let message = code;
|
||||
if (error instanceof Error) {
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
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, ListToolsOptions, Runtime } from '../runtime.js';
|
||||
import type {
|
||||
CallOptions,
|
||||
ConnectOptions,
|
||||
ListResourcesOptions,
|
||||
ListToolsOptions,
|
||||
ReadResourceOptions,
|
||||
Runtime,
|
||||
} from '../runtime.js';
|
||||
import type { DaemonClient } from './client.js';
|
||||
|
||||
interface KeepAliveRuntimeOptions {
|
||||
@ -61,6 +67,8 @@ class KeepAliveRuntime implements Runtime {
|
||||
server,
|
||||
includeSchema: options?.includeSchema,
|
||||
autoAuthorize: options?.autoAuthorize,
|
||||
allowCachedAuth: options?.allowCachedAuth ?? true,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})
|
||||
)) as Awaited<ReturnType<Runtime['listTools']>>;
|
||||
}
|
||||
@ -75,30 +83,45 @@ 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?: Partial<ListResourcesRequest['params']>): Promise<unknown> {
|
||||
async listResources(server: string, options?: ListResourcesOptions): Promise<unknown> {
|
||||
if (options?.oauthSessionOptions) {
|
||||
return this.base.listResources(server, options);
|
||||
}
|
||||
const { allowCachedAuth, disableOAuth, ...params } = options ?? {};
|
||||
if (this.shouldUseDaemon(server)) {
|
||||
return this.invokeWithRestart(server, 'listResources', () =>
|
||||
this.daemon.listResources({ server, params: options ?? {} })
|
||||
this.daemon.listResources({ server, params, allowCachedAuth, disableOAuth })
|
||||
);
|
||||
}
|
||||
return this.base.listResources(server, options);
|
||||
}
|
||||
|
||||
async readResource(server: string, uri: string): Promise<unknown> {
|
||||
if (this.shouldUseDaemon(server)) {
|
||||
return this.invokeWithRestart(server, 'readResource', () => this.daemon.readResource({ server, uri }));
|
||||
async readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown> {
|
||||
if (options?.oauthSessionOptions) {
|
||||
return this.base.readResource(server, uri, options);
|
||||
}
|
||||
return this.base.readResource(server, uri);
|
||||
if (this.shouldUseDaemon(server)) {
|
||||
return this.invokeWithRestart(server, 'readResource', () =>
|
||||
this.daemon.readResource({
|
||||
server,
|
||||
uri,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.base.readResource(server, uri, options);
|
||||
}
|
||||
|
||||
async connect(server: string): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||
return this.base.connect(server);
|
||||
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||
return this.base.connect(server, options);
|
||||
}
|
||||
|
||||
async close(server?: string): Promise<void> {
|
||||
|
||||
117
src/fs-json.ts
117
src/fs-json.ts
@ -8,6 +8,7 @@ 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> {
|
||||
@ -64,49 +65,51 @@ 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();
|
||||
let acquired = false;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
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 {
|
||||
@ -118,6 +121,46 @@ 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);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
bundleOutput,
|
||||
@ -10,8 +11,10 @@ import { ensureInvocationDefaults, fetchTools, resolveServerDefinition } from '.
|
||||
import { resolveRuntimeKind } from './cli/generate/runtime.js';
|
||||
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
|
||||
import type { ToolMetadata } from './cli/generate/tools.js';
|
||||
import { buildToolMetadata, toolsTestHelpers } from './cli/generate/tools.js';
|
||||
import { buildToolMetadataList, toolsTestHelpers } from './cli/generate/tools.js';
|
||||
import { type CliArtifactMetadata, serializeDefinition } from './cli-metadata.js';
|
||||
import { stableJsonStringify } from './cli/generate/stable-json.js';
|
||||
import type { ServerDefinition } from './config.js';
|
||||
import type { ServerToolInfo } from './runtime.js';
|
||||
|
||||
export interface GenerateCliOptions {
|
||||
@ -29,6 +32,8 @@ export interface GenerateCliOptions {
|
||||
readonly excludeTools?: string[];
|
||||
}
|
||||
|
||||
const REPRODUCIBLE_GENERATED_AT = '1970-01-01T00:00:00.000Z';
|
||||
|
||||
// generateCli produces a standalone CLI (and optional bundle/binary) for a given MCP server.
|
||||
export async function generateCli(
|
||||
options: GenerateCliOptions
|
||||
@ -55,7 +60,9 @@ export async function generateCli(
|
||||
baseDefinition.description || !derivedDescription
|
||||
? baseDefinition
|
||||
: { ...baseDefinition, description: derivedDescription };
|
||||
const toolMetadata: ToolMetadata[] = tools.map((tool) => buildToolMetadata(tool));
|
||||
const embeddedDefinition = stripBuildSources(definition);
|
||||
const serializedDefinition = serializeDefinition(embeddedDefinition);
|
||||
const toolMetadata: ToolMetadata[] = buildToolMetadataList(tools);
|
||||
const generator = await readPackageMetadata();
|
||||
const baseInvocation = ensureInvocationDefaults(
|
||||
{
|
||||
@ -72,32 +79,30 @@ export async function generateCli(
|
||||
includeTools: options.includeTools,
|
||||
excludeTools: options.excludeTools,
|
||||
},
|
||||
definition
|
||||
embeddedDefinition
|
||||
);
|
||||
const embeddedMetadata: CliArtifactMetadata = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt: REPRODUCIBLE_GENERATED_AT,
|
||||
generator,
|
||||
server: {
|
||||
name,
|
||||
source: definition.source,
|
||||
definition: serializeDefinition(definition),
|
||||
definition: serializedDefinition,
|
||||
},
|
||||
artifact: {
|
||||
path: '',
|
||||
kind: 'template',
|
||||
},
|
||||
invocation: baseInvocation,
|
||||
invocation: buildEmbeddedInvocation(baseInvocation, serializedDefinition),
|
||||
};
|
||||
|
||||
const shouldBundle = Boolean(options.bundle ?? options.compile);
|
||||
let templateTmpDir: string | undefined;
|
||||
let templateOutputPath = options.outputPath;
|
||||
if (!templateOutputPath && shouldBundle) {
|
||||
const tmpPrefix = path.join(process.cwd(), 'tmp', 'mcporter-cli-');
|
||||
await fs.mkdir(path.dirname(tmpPrefix), { recursive: true });
|
||||
templateTmpDir = await fs.mkdtemp(tmpPrefix);
|
||||
templateOutputPath = path.join(templateTmpDir, `${name}.ts`);
|
||||
templateTmpDir = resolveImplicitTemplateDir(name, serializedDefinition);
|
||||
await fs.mkdir(templateTmpDir, { recursive: true });
|
||||
templateOutputPath = path.join(templateTmpDir, `${sanitizePathSegment(name) || 'server'}.ts`);
|
||||
}
|
||||
const templateSourcePath = path.resolve(templateOutputPath ?? path.resolve(process.cwd(), `${name}.ts`));
|
||||
let resolvedBundleTarget: string | undefined;
|
||||
@ -121,7 +126,7 @@ export async function generateCli(
|
||||
runtimeScriptPath,
|
||||
runtimeKind,
|
||||
timeoutMs,
|
||||
definition,
|
||||
definition: embeddedDefinition,
|
||||
serverName: name,
|
||||
tools: toolMetadata,
|
||||
generator,
|
||||
@ -156,13 +161,49 @@ export async function generateCli(
|
||||
}
|
||||
} finally {
|
||||
if (templateTmpDir) {
|
||||
await fs.rm(templateTmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(outputPath, { force: true }).catch(() => {});
|
||||
await fs.rmdir(templateTmpDir).catch(() => {});
|
||||
await fs.rmdir(path.dirname(templateTmpDir)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return { outputPath: options.outputPath ?? outputPath, bundlePath, compilePath };
|
||||
}
|
||||
|
||||
function stripBuildSources(definition: ServerDefinition): ServerDefinition {
|
||||
const { source: _source, sources: _sources, ...runtimeDefinition } = definition;
|
||||
return runtimeDefinition;
|
||||
}
|
||||
|
||||
function buildEmbeddedInvocation(
|
||||
invocation: CliArtifactMetadata['invocation'],
|
||||
definition: ReturnType<typeof serializeDefinition>
|
||||
): CliArtifactMetadata['invocation'] {
|
||||
return {
|
||||
serverRef: stableJsonStringify(definition),
|
||||
runtime: invocation.runtime,
|
||||
bundler: invocation.bundler,
|
||||
timeoutMs: invocation.timeoutMs,
|
||||
minify: invocation.minify,
|
||||
includeTools: invocation.includeTools,
|
||||
excludeTools: invocation.excludeTools,
|
||||
bundle: typeof invocation.bundle === 'boolean' ? invocation.bundle : undefined,
|
||||
compile: invocation.compile,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImplicitTemplateDir(serverName: string, definition: ReturnType<typeof serializeDefinition>): string {
|
||||
const hash = createHash('sha256').update(stableJsonStringify({ serverName, definition })).digest('hex').slice(0, 12);
|
||||
return path.join(process.cwd(), 'tmp', 'mcporter-cli', `${sanitizePathSegment(serverName) || 'server'}-${hash}`);
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function applyToolFilters(tools: ServerToolInfo[], includeTools?: string[], excludeTools?: string[]): ServerToolInfo[] {
|
||||
if (includeTools && excludeTools) {
|
||||
throw new Error('Internal error: both includeTools and excludeTools provided to generateCli.');
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@ -2,7 +2,17 @@ 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, ListToolsOptions, Runtime, RuntimeLogger, ServerToolInfo } from './runtime.js';
|
||||
export type {
|
||||
CallOptions,
|
||||
ConnectOptions,
|
||||
ListResourcesOptions,
|
||||
ListToolsOptions,
|
||||
ReadResourceOptions,
|
||||
Runtime,
|
||||
RuntimeLogger,
|
||||
RuntimeOptions,
|
||||
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';
|
||||
|
||||
@ -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']);
|
||||
const DEFAULT_KEEP_ALIVE = new Set(['chrome-devtools', 'mobile-mcp', 'playwright', 'cloudbase']);
|
||||
|
||||
const includeOverride = parseList(process.env.MCPORTER_KEEPALIVE);
|
||||
const excludeOverride = parseList(process.env.MCPORTER_DISABLE_KEEPALIVE ?? process.env.MCPORTER_NO_KEEPALIVE);
|
||||
@ -19,6 +19,7 @@ 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'];
|
||||
|
||||
@ -62,6 +62,16 @@ 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) {
|
||||
@ -86,6 +96,37 @@ 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;
|
||||
@ -111,7 +152,7 @@ class DirectoryPersistence implements OAuthPersistence {
|
||||
}
|
||||
|
||||
async readTokens(): Promise<OAuthTokens | undefined> {
|
||||
return readJsonFile<OAuthTokens>(this.tokenPath);
|
||||
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
@ -121,7 +162,7 @@ class DirectoryPersistence implements OAuthPersistence {
|
||||
}
|
||||
|
||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
|
||||
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
|
||||
}
|
||||
|
||||
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
||||
@ -146,9 +187,31 @@ class DirectoryPersistence implements OAuthPersistence {
|
||||
}
|
||||
|
||||
async readState(): Promise<string | undefined> {
|
||||
// Deliberately NOT corrupt-tolerant: a corrupt OAuth state must fail the
|
||||
// flow closed. Returning undefined here would skip the CSRF state check on
|
||||
// the authorization callback (see oauth.ts), so only the credential caches
|
||||
// (tokens/client) degrade to re-auth.
|
||||
return readJsonFile<string>(this.statePath);
|
||||
}
|
||||
|
||||
// A present-but-corrupt credential cache (tokens/client) means "no usable
|
||||
// credentials": degrade to re-auth instead of crashing the connection,
|
||||
// mirroring VaultPersistence and the daemon/server-proxy readers. Genuine I/O
|
||||
// faults still propagate (readJsonFile re-throws everything except ENOENT).
|
||||
// OAuth state is intentionally excluded (see readState) so its CSRF check
|
||||
// still fails closed on a corrupt state file.
|
||||
private async readJsonOrUndefined<T>(filePath: string): Promise<T | undefined> {
|
||||
try {
|
||||
return await readJsonFile<T>(filePath);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error;
|
||||
}
|
||||
this.logger?.debug?.(`Ignoring corrupt OAuth cache file ${filePath}: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async saveState(value: string): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeJsonFile(this.statePath, value);
|
||||
@ -344,7 +407,7 @@ export async function clearOAuthCaches(
|
||||
await legacy.clear(scope);
|
||||
}
|
||||
|
||||
if (definition.tokenCacheDir) {
|
||||
if (definition.tokenCacheDir && scope === 'all') {
|
||||
await fs.rm(definition.tokenCacheDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@ -413,6 +476,22 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,12 @@ interface VaultReadState {
|
||||
needsRepair: boolean;
|
||||
}
|
||||
|
||||
interface SameUrlCredentials {
|
||||
tokens?: OAuthTokens;
|
||||
clientInfo?: OAuthClientInformationMixed;
|
||||
sourceKeys: VaultKey[];
|
||||
}
|
||||
|
||||
export function getOAuthVaultPath(): string {
|
||||
return path.join(mcporterDir('data'), 'credentials.json');
|
||||
}
|
||||
@ -76,14 +82,110 @@ export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
|
||||
|
||||
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
|
||||
const vault = await readVault();
|
||||
return vault.entries[vaultKeyForDefinition(definition)];
|
||||
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;
|
||||
}
|
||||
|
||||
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
|
||||
await withFileLock(getOAuthVaultPath(), async () => {
|
||||
const vault = await readVault();
|
||||
const key = vaultKeyForDefinition(definition);
|
||||
const current = vault.entries[key] ?? {
|
||||
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
|
||||
const fallback = findSameUrlCredentials(vault, definition, key, existing);
|
||||
const current = existing ?? {
|
||||
serverName: definition.name,
|
||||
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@ -91,6 +193,8 @@ 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);
|
||||
@ -104,8 +208,10 @@ export async function clearVaultEntry(
|
||||
const key = vaultKeyForDefinition(definition);
|
||||
await withFileLock(getOAuthVaultPath(), async () => {
|
||||
const { vault, needsRepair } = await readVaultState();
|
||||
const existing = vault.entries[key];
|
||||
if (!existing) {
|
||||
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) {
|
||||
if (needsRepair) {
|
||||
await writeVault(vault);
|
||||
}
|
||||
@ -113,7 +219,7 @@ export async function clearVaultEntry(
|
||||
}
|
||||
if (scope === 'all') {
|
||||
delete vault.entries[key];
|
||||
} else {
|
||||
} else if (existing) {
|
||||
const updated: VaultEntry = { ...existing };
|
||||
if (scope === 'tokens') {
|
||||
delete updated.tokens;
|
||||
@ -130,6 +236,25 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -150,8 +150,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
||||
// previous client registration is cached with a different redirect URI the
|
||||
// auth server will reject the request with `invalid_redirect_uri`. Clear
|
||||
// the stale registration so the next flow re-registers with the new URI.
|
||||
// Wrapped in try/catch so persistence errors (malformed JSON, permission
|
||||
// issues) close the already-bound callback server instead of leaking it.
|
||||
// Wrapped in try/catch so non-recoverable persistence errors (for example,
|
||||
// permission issues) close the already-bound callback server instead of leaking it.
|
||||
if (usesDynamicPort) {
|
||||
try {
|
||||
const cachedClient = await persistence.readClientInfo();
|
||||
|
||||
@ -28,5 +28,10 @@ export function mcporterDir(kind: McporterPathKind): string {
|
||||
|
||||
export function mcporterConfigCandidates(): string[] {
|
||||
const base = mcporterDir('config');
|
||||
return [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')];
|
||||
const candidates = [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')];
|
||||
if (base !== legacyMcporterDir()) {
|
||||
const legacy = legacyMcporterDir();
|
||||
candidates.push(path.join(legacy, 'mcporter.json'), path.join(legacy, 'mcporter.jsonc'));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@ -4,26 +4,40 @@ 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> }
|
||||
transport: Transport & { close(): Promise<void> },
|
||||
options: CloseTransportAndWaitOptions = {}
|
||||
): 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) {
|
||||
logger.warn(`Failed to close transport cleanly: ${(error as Error).message}`);
|
||||
if (options.throwOnCloseError) {
|
||||
closeError = error;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
691
src/runtime.ts
691
src/runtime.ts
@ -6,6 +6,8 @@ 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';
|
||||
@ -16,6 +18,14 @@ 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[];
|
||||
@ -33,6 +43,11 @@ 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 {
|
||||
@ -40,13 +55,49 @@ 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;
|
||||
}
|
||||
|
||||
interface ConnectOptions {
|
||||
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 {
|
||||
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 {
|
||||
@ -57,9 +108,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?: Partial<ListResourcesRequest['params']>): Promise<unknown>;
|
||||
readResource(server: string, uri: string): Promise<unknown>;
|
||||
connect(server: string): Promise<ClientContext>;
|
||||
listResources(server: string, options?: ListResourcesOptions): Promise<unknown>;
|
||||
readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown>;
|
||||
connect(server: string, options?: ConnectOptions): Promise<ClientContext>;
|
||||
close(server?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@ -90,11 +141,13 @@ 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);
|
||||
@ -103,10 +156,18 @@ export async function callOnce(params: {
|
||||
|
||||
class McpRuntime implements Runtime {
|
||||
private readonly definitions: Map<string, ServerDefinition>;
|
||||
private readonly clients = new Map<string, Promise<ClientContext>>();
|
||||
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 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) {
|
||||
@ -119,6 +180,13 @@ 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.
|
||||
@ -145,17 +213,20 @@ 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.clients.delete(definition.name);
|
||||
this.retireCachedEntriesForServer(definition.name);
|
||||
}
|
||||
|
||||
async getInstructions(server: string): Promise<string | undefined> {
|
||||
const contextPromise = this.clients.get(server.trim());
|
||||
if (!contextPromise) {
|
||||
const active = this.activeClientForServer(server);
|
||||
const fallbackEntries = active ? [] : this.cachedEntriesForServer(server);
|
||||
const cached = active ?? (fallbackEntries.length === 1 ? fallbackEntries[0] : undefined);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const context = await contextPromise;
|
||||
const context = await cached.promise;
|
||||
const instructions =
|
||||
typeof context.client.getInstructions === 'function' ? context.client.getInstructions() : undefined;
|
||||
if (typeof instructions !== 'string') {
|
||||
@ -171,15 +242,27 @@ 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: autoAuthorize ? undefined : 0,
|
||||
skipCache: !autoAuthorize,
|
||||
allowCachedAuth: options.allowCachedAuth,
|
||||
maxOAuthAttempts: useLegacyNoAuthorize ? 0 : undefined,
|
||||
skipCache: useLegacyNoAuthorize,
|
||||
allowCachedAuth,
|
||||
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);
|
||||
@ -193,20 +276,25 @@ 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);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!autoAuthorize) {
|
||||
await context.client.close().catch(() => {});
|
||||
await closeTransportAndWait(this.logger, context.transport).catch(() => {});
|
||||
await context.oauthSession?.close().catch(() => {});
|
||||
if (useLegacyNoAuthorize) {
|
||||
try {
|
||||
await this.closeContext(context);
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closeError !== undefined) {
|
||||
throw closeError;
|
||||
}
|
||||
|
||||
return filterTools(tools, this.definitions.get(server.trim()));
|
||||
}
|
||||
|
||||
// callTool executes a tool using the args provided by the caller.
|
||||
@ -217,8 +305,14 @@ class McpRuntime implements Runtime {
|
||||
`Tool '${toolName}' is not accessible on server '${definition.name}' (blocked by configuration).`
|
||||
);
|
||||
}
|
||||
let context: ClientContext | undefined;
|
||||
try {
|
||||
const { client } = await this.connect(server);
|
||||
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||
context = await this.connect(server, {
|
||||
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(server, undefined, disableOAuth, true),
|
||||
disableOAuth,
|
||||
});
|
||||
const { client } = context;
|
||||
const params: CallToolRequest['params'] = {
|
||||
name: toolName,
|
||||
arguments: options.args ?? {},
|
||||
@ -239,117 +333,570 @@ 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);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// listResources delegates to the MCP resources/list method with passthrough params.
|
||||
async listResources(server: string, options: Partial<ListResourcesRequest['params']> = {}): Promise<unknown> {
|
||||
async listResources(server: string, options: ListResourcesOptions = {}): Promise<unknown> {
|
||||
const { allowCachedAuth, disableOAuth, oauthSessionOptions, ...params } = options;
|
||||
let context: ClientContext | undefined;
|
||||
try {
|
||||
const { client } = await this.connect(server);
|
||||
return await client.listResources(options as ListResourcesRequest['params']);
|
||||
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']);
|
||||
} catch (error) {
|
||||
// Fatal listResources errors usually mean the underlying transport has gone away.
|
||||
await this.resetConnectionOnError(server, error);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readResource(server: string, uri: string): Promise<unknown> {
|
||||
async readResource(server: string, uri: string, options: ReadResourceOptions = {}): Promise<unknown> {
|
||||
let context: ClientContext | undefined;
|
||||
try {
|
||||
const { client } = await this.connect(server);
|
||||
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;
|
||||
return await client.readResource({ uri } satisfies ReadResourceRequest['params']);
|
||||
} catch (error) {
|
||||
await this.resetConnectionOnError(server, error);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
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();
|
||||
|
||||
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
|
||||
|
||||
if (useCache) {
|
||||
const existing = this.clients.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const definition = this.definitions.get(normalized);
|
||||
let definition = this.definitions.get(normalized);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||
}
|
||||
const generation = this.serverGeneration(normalized);
|
||||
|
||||
const connection = createClientContext(definition, this.logger, this.clientInfo, {
|
||||
maxOAuthAttempts: options.maxOAuthAttempts,
|
||||
oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS,
|
||||
onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted),
|
||||
allowCachedAuth: options.allowCachedAuth,
|
||||
oauthSessionOptions: options.oauthSessionOptions,
|
||||
});
|
||||
// `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) {
|
||||
this.clients.set(normalized, connection);
|
||||
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 {
|
||||
return await connection;
|
||||
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) {
|
||||
this.clients.delete(normalized);
|
||||
releaseConnectionSetup?.();
|
||||
releaseConnectionSetup = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return connection;
|
||||
let connectionDefinition = definition;
|
||||
let contextPromise = 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,
|
||||
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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
releaseConnectionSetup?.();
|
||||
}
|
||||
}
|
||||
|
||||
releaseConnectionSetup?.();
|
||||
return contextPromise;
|
||||
}
|
||||
|
||||
// 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();
|
||||
const context = await this.clients.get(normalized);
|
||||
if (!context) {
|
||||
return;
|
||||
this.bumpServerGeneration(normalized);
|
||||
const entries = [...this.clients.entries()].filter(([, cached]) => cached.server === normalized);
|
||||
if (entries.length === 0) {
|
||||
this.activeClientKeys.delete(normalized);
|
||||
}
|
||||
await context.client.close().catch(() => {});
|
||||
await closeTransportAndWait(this.logger, context.transport).catch(() => {});
|
||||
await context.oauthSession?.close().catch(() => {});
|
||||
this.clients.delete(normalized);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, promise] of this.clients.entries()) {
|
||||
try {
|
||||
const context = await promise;
|
||||
await context.client.close().catch(() => {});
|
||||
await closeTransportAndWait(this.logger, context.transport).catch(() => {});
|
||||
await context.oauthSession?.close().catch(() => {});
|
||||
} finally {
|
||||
this.clients.delete(name);
|
||||
}
|
||||
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 resetConnectionOnError(server: string, error: unknown): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!shouldResetConnection(error)) {
|
||||
return;
|
||||
}
|
||||
const normalized = server.trim();
|
||||
if (!this.clients.has(normalized)) {
|
||||
if (!failedContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
} 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.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { auth as sdkAuth } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Logger } from '../logging.js';
|
||||
import type { OAuthSession } from '../oauth.js';
|
||||
@ -9,6 +10,7 @@ export const DEFAULT_OAUTH_CODE_TIMEOUT_MS = 300_000;
|
||||
const OAUTH_FLOW_ERROR = Symbol('oauth-flow-error');
|
||||
const POST_AUTH_CONNECT_ERROR = Symbol('post-auth-connect-error');
|
||||
const MAX_OAUTH_ERROR_DETAIL_LENGTH = 1_200;
|
||||
const PROACTIVE_TOKEN_SKEW_SECONDS = 60;
|
||||
|
||||
export interface OAuthCapableTransport extends Transport {
|
||||
close(): Promise<void>;
|
||||
@ -109,6 +111,15 @@ function hasErrorMarker(error: unknown, marker: symbol): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasUsableCachedAccessToken(tokens: OAuthTokens | undefined): boolean {
|
||||
if (!tokens || typeof tokens.access_token !== 'string' || tokens.access_token.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
const stored = tokens as OAuthTokens & { expires_at?: number; expiresAt?: number };
|
||||
const expiresAt = typeof stored.expires_at === 'number' ? stored.expires_at : stored.expiresAt;
|
||||
return typeof expiresAt === 'number' && expiresAt > Math.floor(Date.now() / 1000) + PROACTIVE_TOKEN_SKEW_SECONDS;
|
||||
}
|
||||
|
||||
export async function connectWithAuth(
|
||||
client: Client,
|
||||
transport: OAuthCapableTransport,
|
||||
@ -239,6 +250,10 @@ async function completeProactiveAuthorization(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cachedTokens = await session.provider.tokens?.();
|
||||
if (hasUsableCachedAccessToken(cachedTokens)) {
|
||||
return;
|
||||
}
|
||||
const result = await sdkAuth(session.provider, {
|
||||
serverUrl: options.serverUrl,
|
||||
fetchFn: options.fetchFn,
|
||||
|
||||
181
src/runtime/record-transport.ts
Normal file
181
src/runtime/record-transport.ts
Normal file
@ -0,0 +1,181 @@
|
||||
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;
|
||||
}
|
||||
198
src/runtime/replay-transport.ts
Normal file
198
src/runtime/replay-transport.ts
Normal file
@ -0,0 +1,198 @@
|
||||
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}.`;
|
||||
}
|
||||
@ -21,6 +21,8 @@ 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';
|
||||
@ -84,6 +86,16 @@ 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 {
|
||||
@ -136,6 +148,38 @@ 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);
|
||||
@ -152,7 +196,11 @@ function maybePromoteHttpDefinition(
|
||||
logger: Logger,
|
||||
options: CreateClientContextOptions
|
||||
): ServerDefinition | undefined {
|
||||
if (options.maxOAuthAttempts === 0) {
|
||||
// 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) {
|
||||
return undefined;
|
||||
}
|
||||
return maybeEnableOAuth(definition, logger);
|
||||
@ -251,7 +299,8 @@ async function applyCachedAuthIfAvailable(
|
||||
async function createStdioClientContext(
|
||||
client: Client,
|
||||
definition: ServerDefinition & { command: Extract<ServerDefinition['command'], { kind: 'stdio' }> },
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
options: CreateClientContextOptions
|
||||
): Promise<ClientContext> {
|
||||
const resolvedEnvOverrides =
|
||||
definition.env && Object.keys(definition.env).length > 0
|
||||
@ -271,15 +320,16 @@ async function createStdioClientContext(
|
||||
if (compat.applied) {
|
||||
logger.info(`Injecting chrome-devtools-mcp --autoConnect compatibility patch from ${compat.patchPath}.`);
|
||||
}
|
||||
const transport = new StdioClientTransport({
|
||||
const rawTransport = new StdioClientTransport({
|
||||
command,
|
||||
args: commandArgs,
|
||||
cwd: definition.command.cwd,
|
||||
env: compat.env,
|
||||
});
|
||||
if (STDIO_TRACE_ENABLED) {
|
||||
attachStdioTraceLogging(transport, definition.name ?? definition.command.command);
|
||||
attachStdioTraceLogging(rawTransport, definition.name ?? definition.command.command);
|
||||
}
|
||||
const transport = wrapRecordTransport(rawTransport, definition, options);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
@ -317,7 +367,8 @@ 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;
|
||||
const shouldEstablishOAuth =
|
||||
activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0 && options.disableOAuth !== true;
|
||||
if (shouldEstablishOAuth) {
|
||||
oauthSession = await createOAuthSession(activeDefinition, logger, options.oauthSessionOptions);
|
||||
}
|
||||
@ -376,7 +427,8 @@ async function connectPrimaryHttpTransport(
|
||||
logger: Logger,
|
||||
options: CreateClientContextOptions
|
||||
): Promise<ClientContext> {
|
||||
const createStreamableTransport = () => new StreamableHTTPClientTransport(command.url, transportOptions);
|
||||
const createStreamableTransport = () =>
|
||||
wrapRecordTransport(new StreamableHTTPClientTransport(command.url, transportOptions), definition, options);
|
||||
const transport = await connectHttpTransport(client, createStreamableTransport(), oauthSession, logger, {
|
||||
serverName: definition.name,
|
||||
serverUrl: command.url,
|
||||
@ -404,7 +456,7 @@ async function connectSseFallbackTransport(
|
||||
try {
|
||||
const transport = await connectHttpTransport(
|
||||
client,
|
||||
new SSEClientTransport(command.url, transportOptions),
|
||||
wrapRecordTransport(new SSEClientTransport(command.url, transportOptions), definition, options),
|
||||
oauthSession,
|
||||
logger,
|
||||
{
|
||||
@ -441,6 +493,9 @@ 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 () => {
|
||||
@ -448,7 +503,8 @@ export async function createClientContext(
|
||||
return createStdioClientContext(
|
||||
client,
|
||||
activeDefinition as ServerDefinition & { command: Extract<ServerDefinition['command'], { kind: 'stdio' }> },
|
||||
logger
|
||||
logger,
|
||||
options
|
||||
);
|
||||
}
|
||||
return retryHttpTransportWithFallback(client, activeDefinition, logger, options);
|
||||
|
||||
41
src/serve.ts
41
src/serve.ts
@ -21,6 +21,7 @@ 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 {}
|
||||
@ -53,11 +54,28 @@ 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}`);
|
||||
if (url.pathname !== '/mcp') {
|
||||
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 {
|
||||
response.writeHead(404).end('Not found');
|
||||
return;
|
||||
}
|
||||
const bridgeServer = createBridgeServer(options);
|
||||
const bridgeServer = createBridgeServer(bridgeOptions);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
@ -90,9 +108,14 @@ export async function serveHttp(options: ServeHttpOptions): Promise<http.Server>
|
||||
|
||||
export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
const servedServers = selectServedServers(options.definitions, options.servers);
|
||||
if (servedServers.length === 0) {
|
||||
const [firstServed] = servedServers;
|
||||
if (!firstServed) {
|
||||
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 },
|
||||
@ -100,7 +123,9 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
} satisfies ServerCapabilities,
|
||||
instructions: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
|
||||
instructions: bare
|
||||
? `MCPorter bridge exposing the '${firstServed.name}' server.`
|
||||
: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
|
||||
}
|
||||
);
|
||||
|
||||
@ -119,8 +144,8 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
|
||||
for (const tool of listed) {
|
||||
tools.push({
|
||||
name: encodeToolName(served.name, tool.name),
|
||||
description: describeTool(served.name, tool.description),
|
||||
name: bare ? tool.name : encodeToolName(served.name, tool.name),
|
||||
description: bare ? tool.description : describeTool(served.name, tool.description),
|
||||
inputSchema: normalizeInputSchema(tool.inputSchema),
|
||||
outputSchema: normalizeOutputSchema(tool.outputSchema),
|
||||
});
|
||||
@ -130,7 +155,9 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const target = decodeToolName(request.params.name, servedServers);
|
||||
const target = bare
|
||||
? { server: firstServed.name, tool: request.params.name }
|
||||
: decodeToolName(request.params.name, servedServers);
|
||||
if (!target) {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Unknown bridged tool '${request.params.name}'.`);
|
||||
}
|
||||
|
||||
@ -17,7 +17,16 @@ type ToolSchemaInfo = {
|
||||
propertySet: Set<string>;
|
||||
};
|
||||
|
||||
const KNOWN_OPTION_KEYS = new Set(['tailLog', 'timeout', 'stream', 'streamLog', 'mimeType', 'metadata', 'log']);
|
||||
const KNOWN_OPTION_KEYS = new Set([
|
||||
'disableOAuth',
|
||||
'tailLog',
|
||||
'timeout',
|
||||
'stream',
|
||||
'streamLog',
|
||||
'mimeType',
|
||||
'metadata',
|
||||
'log',
|
||||
]);
|
||||
|
||||
export interface ServerProxyOptions {
|
||||
readonly mapPropertyToTool?: (property: string | symbol) => string;
|
||||
@ -43,6 +52,51 @@ 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') {
|
||||
@ -145,7 +199,7 @@ export function createServerProxy(
|
||||
const toolSchemaCache = new Map<string, ToolSchemaInfo>();
|
||||
const persistedSchemas = new Map<string, Record<string, unknown>>();
|
||||
const toolAliasMap = new Map<string, string>();
|
||||
let schemaFetch: Promise<void> | null = null;
|
||||
const schemaFetches = new Map<string, Promise<void>>();
|
||||
let diskLoad: Promise<void> | null = null;
|
||||
let persistPromise: Promise<void> | null = null;
|
||||
let refreshPending = false;
|
||||
@ -184,7 +238,13 @@ export function createServerProxy(
|
||||
}
|
||||
|
||||
// ensureMetadata loads schema information for the requested tool, optionally refreshing from the server.
|
||||
async function ensureMetadata(toolName: string): Promise<ToolSchemaInfo | undefined> {
|
||||
// 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> {
|
||||
await consumePersist();
|
||||
const cached = toolSchemaCache.get(toolName);
|
||||
if (cached && !refreshPending) {
|
||||
@ -202,9 +262,28 @@ 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, { includeSchema: true })
|
||||
.listTools(serverName, listToolsOptions)
|
||||
.then((tools) => {
|
||||
for (const tool of tools) {
|
||||
if (!tool.inputSchema || typeof tool.inputSchema !== 'object') {
|
||||
@ -216,9 +295,12 @@ export function createServerProxy(
|
||||
refreshPending = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
schemaFetch = null;
|
||||
if (schemaFetches.get(schemaFetchKey) === schemaFetch) {
|
||||
schemaFetches.delete(schemaFetchKey);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
schemaFetches.set(schemaFetchKey, schemaFetch);
|
||||
}
|
||||
|
||||
await schemaFetch;
|
||||
@ -301,9 +383,11 @@ export function createServerProxy(
|
||||
: mapPropertyToTool(propertyKey);
|
||||
|
||||
return async (...callArgs: unknown[]) => {
|
||||
const { options: metadataOptions, optionObjects } = inferMetadataOptions(callArgs);
|
||||
|
||||
let schemaInfo: ToolSchemaInfo | undefined;
|
||||
try {
|
||||
schemaInfo = await ensureMetadata(resolvedToolName);
|
||||
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
|
||||
} catch {
|
||||
schemaInfo = undefined;
|
||||
}
|
||||
@ -312,7 +396,7 @@ export function createServerProxy(
|
||||
if (alias && alias !== resolvedToolName) {
|
||||
resolvedToolName = alias;
|
||||
try {
|
||||
schemaInfo = await ensureMetadata(resolvedToolName);
|
||||
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
|
||||
} catch {
|
||||
// ignore and keep prior schema if available
|
||||
}
|
||||
@ -327,6 +411,7 @@ 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)) ||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseCallArguments } from '../src/cli/call-arguments.js';
|
||||
|
||||
@ -93,6 +95,55 @@ describe('parseCallArguments', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reads exact UTF-8 text from @path named argument values', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
|
||||
const payloadPath = path.join(tempDir, 'payload.txt');
|
||||
fs.writeFileSync(payloadPath, 'first line\nsecond line\n', 'utf8');
|
||||
try {
|
||||
const parsed = parseCallArguments(['server.tool', `body=@${payloadPath}`]);
|
||||
expect(parsed.args.body).toBe('first line\nsecond line\n');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('supports @path through generic long tool flags', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
|
||||
const payloadPath = path.join(tempDir, 'payload.txt');
|
||||
fs.writeFileSync(payloadPath, 'from file', 'utf8');
|
||||
try {
|
||||
const parsed = parseCallArguments(['server.tool', '--body', `@${payloadPath}`]);
|
||||
expect(parsed.args.body).toBe('from file');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves whitespace-only generic long flag values', () => {
|
||||
const parsed = parseCallArguments(['server.tool', '--body', ' ']);
|
||||
expect(parsed.args.body).toBe(' ');
|
||||
});
|
||||
|
||||
it('uses @@ to preserve a literal leading @ without reading a file', () => {
|
||||
const parsed = parseCallArguments(['server.tool', 'body=@@literal']);
|
||||
expect(parsed.args.body).toBe('@literal');
|
||||
});
|
||||
|
||||
it('reports missing and non-UTF-8 argument files before transport', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
|
||||
const invalidPath = path.join(tempDir, 'invalid.bin');
|
||||
fs.writeFileSync(invalidPath, Buffer.from([0xc3, 0x28]));
|
||||
try {
|
||||
expect(() => parseCallArguments(['server.tool', `body=@${path.join(tempDir, 'missing.txt')}`])).toThrow(
|
||||
/Unable to read argument file/
|
||||
);
|
||||
expect(() => parseCallArguments(['server.tool', `body=@${invalidPath}`])).toThrow(/not valid UTF-8 text/);
|
||||
expect(() => parseCallArguments(['server.tool', 'body=@'])).toThrow(/requires a path/);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when generic long flags are missing a value', () => {
|
||||
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
|
||||
});
|
||||
@ -175,6 +226,12 @@ 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');
|
||||
|
||||
@ -82,7 +82,12 @@ describe('CLI call execution behavior', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(listTools).toHaveBeenCalledWith('slack', { autoAuthorize: true, includeSchema: true });
|
||||
expect(listTools).toHaveBeenCalledWith('slack', {
|
||||
autoAuthorize: true,
|
||||
includeSchema: true,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
@ -117,7 +122,12 @@ describe('CLI call execution behavior', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(listTools).toHaveBeenCalledWith('email', { autoAuthorize: true, includeSchema: true });
|
||||
expect(listTools).toHaveBeenCalledWith('email', {
|
||||
autoAuthorize: true,
|
||||
includeSchema: true,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
@ -326,7 +336,12 @@ describe('CLI call execution behavior', () => {
|
||||
expect(callTool).toHaveBeenCalledTimes(2);
|
||||
expect(callTool).toHaveBeenNthCalledWith(1, 'linear', 'listIssues', expect.objectContaining({ args: {} }));
|
||||
expect(callTool).toHaveBeenNthCalledWith(2, 'linear', 'list_issues', expect.objectContaining({ args: {} }));
|
||||
expect(listTools).toHaveBeenCalledWith('linear', { autoAuthorize: true, includeSchema: false });
|
||||
expect(listTools).toHaveBeenCalledWith('linear', {
|
||||
autoAuthorize: true,
|
||||
includeSchema: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
process.env.MCPORTER_DISABLE_AUTORUN = '1';
|
||||
|
||||
@ -33,9 +33,12 @@ 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({
|
||||
@ -46,6 +49,10 @@ 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(() => {});
|
||||
@ -81,4 +88,30 @@ 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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@ -10,6 +10,13 @@ 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/);
|
||||
|
||||
@ -34,12 +34,50 @@ async function ensureDistBuilt(): Promise<void> {
|
||||
|
||||
async function hasBun(): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
execFile('bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
|
||||
execFile(process.env.BUN_BIN ?? 'bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let bunCompileSupport: Promise<boolean> | undefined;
|
||||
|
||||
async function hasRunnableBunCompile(): Promise<boolean> {
|
||||
bunCompileSupport ??= probeRunnableBunCompile();
|
||||
return await bunCompileSupport;
|
||||
}
|
||||
|
||||
async function probeRunnableBunCompile(): Promise<boolean> {
|
||||
if (!(await hasBun())) {
|
||||
return false;
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bun-compile-probe-'));
|
||||
const sourcePath = path.join(tempDir, 'probe.ts');
|
||||
const binaryPath = path.join(tempDir, 'probe');
|
||||
try {
|
||||
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
|
||||
const bun = process.env.BUN_BIN ?? 'bun';
|
||||
const built = await new Promise<boolean>((resolve) => {
|
||||
execFile(
|
||||
bun,
|
||||
['build', sourcePath, '--compile', '--outfile', binaryPath],
|
||||
{ cwd: tempDir, env: process.env },
|
||||
(error) => resolve(!error)
|
||||
);
|
||||
});
|
||||
if (!built) {
|
||||
return false;
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
execFile(binaryPath, [], { cwd: tempDir, env: process.env }, (error, stdout) => {
|
||||
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBunSupport(reason: string): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
console.warn(`bun not supported on Windows; skipping ${reason}.`);
|
||||
@ -52,6 +90,17 @@ async function ensureBunSupport(reason: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureRunnableBunCompile(reason: string): Promise<boolean> {
|
||||
if (!(await ensureBunSupport(reason))) {
|
||||
return false;
|
||||
}
|
||||
if (!(await hasRunnableBunCompile())) {
|
||||
console.warn(`bun-compiled binaries cannot run on this runner; skipping ${reason}.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGeneratedCli(
|
||||
bundlePath: string,
|
||||
args: string[],
|
||||
@ -566,7 +615,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
||||
}, 20000);
|
||||
|
||||
it('runs "node dist/cli.js generate-cli --compile" when bun is available', async () => {
|
||||
if (!(await ensureBunSupport('compile integration test'))) {
|
||||
if (!(await ensureRunnableBunCompile('compile integration test'))) {
|
||||
return;
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-'));
|
||||
@ -616,7 +665,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
||||
}, 20000);
|
||||
|
||||
it('end-to-end: compiles a "bun" CLI and calls ping', async () => {
|
||||
if (!(await ensureBunSupport('Bun CLI end-to-end test'))) {
|
||||
if (!(await ensureRunnableBunCompile('Bun CLI end-to-end test'))) {
|
||||
return;
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-bun-'));
|
||||
@ -690,7 +739,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
||||
}, 30000);
|
||||
|
||||
it('runs "node dist/cli.js generate-cli --compile" using the Bun bundler by default', async () => {
|
||||
if (!(await ensureBunSupport('Bun bundler compile integration test'))) {
|
||||
if (!(await ensureRunnableBunCompile('Bun bundler compile integration test'))) {
|
||||
return;
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-bun-'));
|
||||
@ -739,7 +788,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
||||
}, 20000);
|
||||
|
||||
it('accepts inline stdio commands (e.g., "npx -y chrome-devtools-mcp@latest") when compiling', async () => {
|
||||
if (!(await ensureBunSupport('inline stdio compile integration test'))) {
|
||||
if (!(await ensureRunnableBunCompile('inline stdio compile integration test'))) {
|
||||
return;
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-inline-stdio-'));
|
||||
@ -884,7 +933,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
||||
console.warn('set MCPORTER_STANDALONE_BINARY_TEST=1 to run standalone Bun release binary smoke');
|
||||
return;
|
||||
}
|
||||
if (!(await ensureBunSupport('standalone Bun release binary smoke'))) {
|
||||
if (!(await ensureRunnableBunCompile('standalone Bun release binary smoke'))) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@ -45,4 +45,17 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,4 +12,10 @@ describe('inspect-cli flag parsing', () => {
|
||||
it('validates explicit format values', () => {
|
||||
expect(() => inspectInternals.parseInspectFlags(['--format', 'xml', 'artifact'])).toThrow(/format/);
|
||||
});
|
||||
|
||||
it('rejects extra positional arguments', () => {
|
||||
expect(() => inspectInternals.parseInspectFlags(['artifact', 'shadow'])).toThrow(
|
||||
/Unexpected inspect-cli argument 'shadow'/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -260,6 +260,7 @@ describe('CLI list classification and routing', () => {
|
||||
expect(listTools).toHaveBeenCalledWith('linear', {
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
@ -330,6 +331,8 @@ 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 = {
|
||||
@ -343,13 +346,17 @@ describe('CLI list classification and routing', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleList(runtime, ['zzz']);
|
||||
try {
|
||||
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();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,10 @@ describe('CLI list flag parsing', () => {
|
||||
verbose: false,
|
||||
ephemeral: undefined,
|
||||
format: 'text',
|
||||
quiet: false,
|
||||
exitCode: false,
|
||||
statusOnly: false,
|
||||
disableOAuth: false,
|
||||
});
|
||||
expect(args).toEqual(['server']);
|
||||
});
|
||||
@ -33,10 +37,22 @@ describe('CLI list flag parsing', () => {
|
||||
verbose: false,
|
||||
ephemeral: undefined,
|
||||
format: 'text',
|
||||
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'];
|
||||
@ -46,6 +62,30 @@ describe('CLI list flag parsing', () => {
|
||||
expect(args).toEqual(['server']);
|
||||
});
|
||||
|
||||
it('parses status check flags', async () => {
|
||||
const { extractListFlags } = await cliModulePromise;
|
||||
const quietArgs = ['--quiet', 'server'];
|
||||
const quietFlags = extractListFlags(quietArgs);
|
||||
expect(quietFlags.quiet).toBe(true);
|
||||
expect(quietFlags.exitCode).toBe(true);
|
||||
expect(quietArgs).toEqual(['server']);
|
||||
|
||||
const statusArgs = ['--status', '--exit-code', 'server'];
|
||||
const statusFlags = extractListFlags(statusArgs);
|
||||
expect(statusFlags.statusOnly).toBe(true);
|
||||
expect(statusFlags.exitCode).toBe(true);
|
||||
expect(statusArgs).toEqual(['server']);
|
||||
});
|
||||
|
||||
it('rejects --status with tool-doc display flags', async () => {
|
||||
const { extractListFlags } = await cliModulePromise;
|
||||
expect(() => extractListFlags(['--status', '--brief', 'server'])).toThrow('--status cannot be used with --brief');
|
||||
expect(() => extractListFlags(['--status', '--schema', 'server'])).toThrow('--status cannot be used with --schema');
|
||||
expect(() => extractListFlags(['--status', '--all-parameters', 'server'])).toThrow(
|
||||
'--status cannot be used with --all-parameters'
|
||||
);
|
||||
});
|
||||
|
||||
it('parses --brief and --signatures aliases', async () => {
|
||||
const { extractListFlags } = await cliModulePromise;
|
||||
const briefArgs = ['--brief', 'server'];
|
||||
|
||||
@ -37,19 +37,87 @@ 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(() => {});
|
||||
|
||||
await runHandleList(runtime, ['--json']);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets a non-zero exit code for unhealthy multi-server checks when requested', async () => {
|
||||
const runtime = createRuntime();
|
||||
const previousExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runHandleList(runtime, ['--json', '--exit-code']);
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
|
||||
expect(payload.counts.auth).toBe(1);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('suppresses output and sets the exit code for quiet checks', async () => {
|
||||
const runtime = createRuntime();
|
||||
const previousExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runHandleList(runtime, ['--quiet']);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('emits a concise single-server status payload', async () => {
|
||||
const runtime = createRuntime();
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runHandleList(runtime, ['healthy', '--status', '--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(payload.counts.ok).toBe(1);
|
||||
expect(payload.servers).toHaveLength(1);
|
||||
expect(payload.servers[0].name).toBe('healthy');
|
||||
expect(payload.servers[0].status).toBe('ok');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects status checks for configured tool selectors', async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runHandleList(runtime, ['healthy.list_documents', '--status'])).rejects.toThrow(
|
||||
'--status cannot be used with a tool selector.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
67
tests/cli-metadata.test.ts
Normal file
67
tests/cli-metadata.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { metadataPathForArtifact, readCliMetadata } from '../src/cli-metadata.js';
|
||||
|
||||
describe('readCliMetadata', () => {
|
||||
it('prefers embedded metadata over stale sidecar metadata', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-metadata-'));
|
||||
const artifact = path.join(tempDir, process.platform === 'win32' ? 'artifact.exe' : 'artifact');
|
||||
const embedded = metadataPayload('embedded');
|
||||
const sidecar = metadataPayload('sidecar');
|
||||
const previousEmbeddedMetadata = process.env.MCPORTER_TEST_EMBEDDED_METADATA;
|
||||
const previousNodeOptions = process.env.NODE_OPTIONS;
|
||||
process.env.MCPORTER_TEST_EMBEDDED_METADATA = JSON.stringify(embedded);
|
||||
if (process.platform === 'win32') {
|
||||
const preload = path.join(tempDir, 'inspect-preload.cjs');
|
||||
await fs.copyFile(process.execPath, artifact);
|
||||
await fs.writeFile(
|
||||
preload,
|
||||
'console.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA); process.exit(0);\n',
|
||||
'utf8'
|
||||
);
|
||||
const requirePath = preload.replaceAll(path.sep, path.posix.sep);
|
||||
process.env.NODE_OPTIONS = `${previousNodeOptions ? `${previousNodeOptions} ` : ''}--require ${requirePath}`;
|
||||
} else {
|
||||
const artifactContent = '#!/usr/bin/env node\nconsole.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA);\n';
|
||||
await fs.writeFile(artifact, artifactContent, 'utf8');
|
||||
await fs.chmod(artifact, 0o755);
|
||||
}
|
||||
await fs.writeFile(metadataPathForArtifact(artifact), JSON.stringify(sidecar), 'utf8');
|
||||
|
||||
try {
|
||||
await expect(readCliMetadata(artifact)).resolves.toMatchObject({
|
||||
server: { name: 'embedded' },
|
||||
});
|
||||
} finally {
|
||||
if (previousEmbeddedMetadata === undefined) {
|
||||
delete process.env.MCPORTER_TEST_EMBEDDED_METADATA;
|
||||
} else {
|
||||
process.env.MCPORTER_TEST_EMBEDDED_METADATA = previousEmbeddedMetadata;
|
||||
}
|
||||
if (previousNodeOptions === undefined) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = previousNodeOptions;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function metadataPayload(name: string) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: '1970-01-01T00:00:00.000Z',
|
||||
generator: { name: 'mcporter', version: 'test' },
|
||||
server: {
|
||||
name,
|
||||
definition: {
|
||||
name,
|
||||
command: { kind: 'stdio' as const, command: 'node', args: [], cwd: process.cwd() },
|
||||
},
|
||||
},
|
||||
artifact: { path: '', kind: 'template' as const },
|
||||
invocation: { runtime: 'node' as const, timeoutMs: 30_000, minify: false },
|
||||
};
|
||||
}
|
||||
@ -52,6 +52,13 @@ describe('mcporter --oauth-timeout flag', () => {
|
||||
createRuntimeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects malformed --oauth-timeout values', async () => {
|
||||
const { runCli } = await import('../src/cli.js');
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(runCli(['--oauth-timeout', '5000abc', 'list'])).rejects.toThrow(/process\.exit/);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('positive integer'));
|
||||
});
|
||||
|
||||
it('returns once runtime.listTools surfaces an OAuth timeout error', async () => {
|
||||
const definition = {
|
||||
name: 'fake',
|
||||
|
||||
@ -53,6 +53,17 @@ 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'));
|
||||
|
||||
26
tests/cli-timeouts.test.ts
Normal file
26
tests/cli-timeouts.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { consumeTimeoutFlag, parseTimeout } from '../src/cli/timeouts.js';
|
||||
|
||||
describe('CLI timeout parsing', () => {
|
||||
it('accepts positive integer millisecond values', () => {
|
||||
expect(parseTimeout('2500', 30_000)).toBe(2_500);
|
||||
|
||||
const args = ['--timeout', '7500', 'server'];
|
||||
expect(consumeTimeoutFlag(args, 0)).toBe(7_500);
|
||||
expect(args).toEqual(['server']);
|
||||
});
|
||||
|
||||
it('falls back for non-positive and partially numeric environment values', () => {
|
||||
for (const value of ['0', '-1', '1s', '10abc', '100.5']) {
|
||||
expect(parseTimeout(value, 30_000)).toBe(30_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-positive and partially numeric CLI flag values', () => {
|
||||
for (const value of ['0', '-1', '1s', '10abc', '100.5']) {
|
||||
expect(() => consumeTimeoutFlag(['--timeout', value], 0)).toThrow(
|
||||
'--timeout must be a positive integer (milliseconds).'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -223,6 +223,48 @@ describe('config imports', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a later imported duplicate when an earlier import has unresolved placeholders', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-import-fallback-'));
|
||||
try {
|
||||
const configPath = path.join(tempRoot, 'config', 'mcporter.json');
|
||||
const cursorPath = path.join(tempRoot, '.cursor', 'mcp.json');
|
||||
const claudePath = path.join(ensureFakeHomeDir(), '.claude', 'settings.json');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {}, imports: ['cursor', 'claude-code'] }));
|
||||
fs.writeFileSync(
|
||||
cursorPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
shared: { command: 'cursor-mcp', args: ['${workspaceFolder}'] },
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
claudePath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
shared: { command: 'claude-mcp', args: ['--usable'] },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const servers = await loadServerDefinitions({ configPath, rootDir: tempRoot });
|
||||
const shared = servers.find((server) => server.name === 'shared');
|
||||
|
||||
expect(shared?.command.kind).toBe('stdio');
|
||||
expect(shared?.command.kind === 'stdio' ? shared.command.command : undefined).toBe('claude-mcp');
|
||||
expect(shared?.source).toEqual({
|
||||
kind: 'import',
|
||||
path: claudePath,
|
||||
importKind: 'claude-code',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('loads Claude project-scoped servers without treating metadata as servers', async () => {
|
||||
const homeDir = ensureFakeHomeDir();
|
||||
const claudeDir = path.join(homeDir, '.claude');
|
||||
|
||||
@ -145,6 +145,30 @@ describe('loadServerDefinitions with layered configs', () => {
|
||||
expect(servers.map((server) => server.name)).toEqual(['fromHome']);
|
||||
});
|
||||
|
||||
it('falls back to legacy home config when an embedder sets an unrelated empty XDG_CONFIG_HOME', async () => {
|
||||
const homeDir =
|
||||
tempHomeDir ??
|
||||
(() => {
|
||||
throw new Error('tempHomeDir missing');
|
||||
})();
|
||||
const projectDir =
|
||||
tempProjectDir ??
|
||||
(() => {
|
||||
throw new Error('tempProjectDir missing');
|
||||
})();
|
||||
|
||||
process.env.XDG_CONFIG_HOME = path.join(homeDir, 'embedder-private-xdg');
|
||||
const legacyConfigDir = path.join(homeDir, '.mcporter');
|
||||
await fs.mkdir(legacyConfigDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(legacyConfigDir, 'mcporter.json'),
|
||||
JSON.stringify({ mcpServers: { qmd: { command: 'node', args: ['qmd-server.js'] } } }, null, 2)
|
||||
);
|
||||
|
||||
const servers = await loadServerDefinitions({ rootDir: projectDir });
|
||||
expect(servers.map((server) => server.name)).toEqual(['qmd']);
|
||||
});
|
||||
|
||||
it('uses explicit config path without merging when set', async () => {
|
||||
const homeDir =
|
||||
tempHomeDir ??
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadServerDefinitions } from '../src/config.js';
|
||||
import { loadDaemonConfig, loadServerDefinitions } from '../src/config.js';
|
||||
|
||||
const TEMP_DIR = path.join(os.tmpdir(), 'mcporter-config-test');
|
||||
|
||||
@ -159,6 +159,27 @@ describe('config normalization', () => {
|
||||
expect(servers.find((entry) => entry.name === 'defaulted')?.httpFetch).toBe('default');
|
||||
});
|
||||
|
||||
it('loads daemon idle timeout from config layers', async () => {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-idle-'));
|
||||
const configDir = path.join(rootDir, 'config');
|
||||
const configPath = path.join(configDir, 'mcporter.json');
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
daemonIdleTimeoutMs: 12_345,
|
||||
mcpServers: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(loadDaemonConfig({ rootDir })).resolves.toEqual({ idleTimeoutMs: 12_345 });
|
||||
});
|
||||
|
||||
it('normalizes refreshable bearer config for stdio servers', async () => {
|
||||
await fs.mkdir(TEMP_DIR, { recursive: true });
|
||||
const configPath = path.join(TEMP_DIR, 'mcporter-refreshable-stdio.json');
|
||||
|
||||
@ -115,4 +115,19 @@ describe('resolveConfigPath', () => {
|
||||
expect(resolved.path).toBe(xdgConfigPath);
|
||||
expect(resolved.explicit).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the legacy home config when XDG config home has no mcporter config', () => {
|
||||
const tempRoot = makeTempDir('mcporter-project-empty-xdg-');
|
||||
const fakeHome = makeTempDir('mcporter-empty-xdg-home-');
|
||||
tempDirs.push(tempRoot, fakeHome);
|
||||
const legacyConfigPath = path.join(fakeHome, '.mcporter', 'mcporter.json');
|
||||
fs.mkdirSync(path.dirname(legacyConfigPath), { recursive: true });
|
||||
fs.writeFileSync(legacyConfigPath, '{"mcpServers":{}}');
|
||||
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
|
||||
process.env.XDG_CONFIG_HOME = path.join(fakeHome, '.empty-xdg-config');
|
||||
|
||||
const resolved = resolveConfigPath(undefined, tempRoot);
|
||||
expect(resolved.path).toBe(legacyConfigPath);
|
||||
expect(resolved.explicit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,7 +40,7 @@ function buildResponse(method: string, id: string) {
|
||||
id,
|
||||
ok: true,
|
||||
result: {
|
||||
pid: 123,
|
||||
pid: activeStatusPid,
|
||||
startedAt: Date.now(),
|
||||
configPath: activeConfigPath,
|
||||
configMtimeMs: activeConfigMtime,
|
||||
@ -59,6 +59,7 @@ 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 }> = [];
|
||||
@ -84,6 +85,28 @@ 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 () => {
|
||||
@ -102,10 +125,12 @@ 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 });
|
||||
@ -113,7 +138,7 @@ describe('DaemonClient config freshness', () => {
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: 1111,
|
||||
pid: deadPid,
|
||||
socketPath,
|
||||
configPath,
|
||||
startedAt: Date.now() - 10_000,
|
||||
@ -143,10 +168,12 @@ 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 });
|
||||
@ -154,7 +181,7 @@ describe('DaemonClient config freshness', () => {
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: 1111,
|
||||
pid: deadPid,
|
||||
socketPath,
|
||||
configPath,
|
||||
startedAt: Date.now() - 10_000,
|
||||
@ -189,6 +216,7 @@ 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 });
|
||||
@ -196,7 +224,7 @@ describe('DaemonClient config freshness', () => {
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: 1111,
|
||||
pid: process.pid,
|
||||
socketPath,
|
||||
configPath,
|
||||
startedAt: Date.now() - 10_000,
|
||||
@ -213,8 +241,21 @@ describe('DaemonClient config freshness', () => {
|
||||
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
await client.listTools({ server: 'playwright' });
|
||||
|
||||
expect(sentMethods).toEqual(['listTools']);
|
||||
expect(sentMethods).toEqual(['status', '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.');
|
||||
}
|
||||
|
||||
230
tests/daemon-client-lifecycle.test.ts
Normal file
230
tests/daemon-client-lifecycle.test.ts
Normal file
@ -0,0 +1,230 @@
|
||||
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.');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user