Compare commits
78 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 | ||
|
|
46cc31cafe | ||
|
|
dd000bdec4 | ||
|
|
2ce585a1eb | ||
|
|
c87150895d | ||
|
|
7f1e9a8ce0 | ||
|
|
3e06e582ef | ||
|
|
2171c1f209 | ||
|
|
33afa744e0 | ||
|
|
23565e2166 | ||
|
|
8d962fbd79 | ||
|
|
907ba78d98 | ||
|
|
eee954e4a1 | ||
|
|
89f5053c15 | ||
|
|
bfe727150c | ||
|
|
6879a69f49 | ||
|
|
7ddb433479 | ||
|
|
b29854ebf2 | ||
|
|
033abb4358 | ||
|
|
f9f60d7cc4 | ||
|
|
0ea394356f | ||
|
|
ed698d9e48 | ||
|
|
a64e29b4fe | ||
|
|
ea91086273 | ||
|
|
5d71e9ce49 |
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
|
||||||
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@ -7,23 +7,71 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
name: build (${{ matrix.os }})
|
||||||
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-15, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: corepack prepare pnpm@10.33.2 --activate
|
- run: corepack prepare pnpm@10.33.2 --activate
|
||||||
|
|
||||||
|
- name: Locate pnpm store
|
||||||
|
id: pnpm-store
|
||||||
|
shell: bash
|
||||||
|
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- run: pnpm check
|
- 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'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
pnpm generate:schema
|
||||||
|
pnpm exec oxfmt mcporter.schema.json
|
||||||
|
git diff --exit-code -- mcporter.schema.json
|
||||||
|
|
||||||
|
- name: Build docs site
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: pnpm docs:site
|
||||||
|
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
|
|
||||||
|
- name: Pack npm artifact
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: pnpm pack --pack-destination /tmp
|
||||||
|
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
env:
|
env:
|
||||||
FIRECRAWL_API_KEY: test
|
FIRECRAWL_API_KEY: test
|
||||||
|
|||||||
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 }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-bun
|
dist-bun
|
||||||
|
|||||||
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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
86
CHANGELOG.md
86
CHANGELOG.md
@ -1,8 +1,90 @@
|
|||||||
# mcporter Changelog
|
# mcporter Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.12.1] - 2026-06-18
|
||||||
|
|
||||||
- Nothing yet.
|
- 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
|
||||||
|
|
||||||
|
- Make `generate-cli --runtime node --bundle <name>.mjs` emit an ES module bundle with a local `require` shim, fixing `.mjs` artifacts that previously crashed at startup.
|
||||||
|
- Classify generated `.mjs` and `.cjs` outputs as bundle artifacts in embedded metadata instead of reporting them as binaries.
|
||||||
|
- Avoid leaving implicit `<server>.ts` template files in the current directory when generating bundle-only artifacts without `--output`.
|
||||||
|
- Print generated CLI help with a trailing newline so subsequent shell output no longer glues onto the help footer.
|
||||||
|
- Point generated CLI metadata and npm package metadata at `openclaw/mcporter`.
|
||||||
|
- Document the existing `generate-cli --timeout`, `--minify`, and `--no-minify` flags in `generate-cli --help`.
|
||||||
|
- Suppress expected Rolldown unresolved-import warnings for Node built-ins during successful generated CLI bundling.
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-05-14
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
- Support `auth: "refreshable_bearer"` with explicit `refresh` settings so cached OAuth tokens can be refreshed before HTTP connects or injected into stdio env vars. (Issue #173, thanks @tokyo-s)
|
||||||
|
- Add `httpFetch: "node-http1"` for HTTP MCP servers whose providers reject Node's built-in `fetch`, and auto-apply it to Sunsama's endpoint. (Issue #158, thanks @mattash)
|
||||||
|
- Resolve `${VAR}` and `${VAR:-fallback}` placeholders across string-valued server config fields such as `baseUrl`, `command`/`args`, `tokenCacheDir`, and pre-registered OAuth fields while keeping headers/env/bearer-token placeholders lazy until runtime. (PR #161 / issue #157, thanks @zxyasfas)
|
||||||
|
- Add `mcporter vault set <server>` and `mcporter vault clear <server>` so headless deployments can seed or clear OAuth vault credentials without reproducing mcporter's internal vault-key format. (Issue #156)
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- Add `mcporter serve`, exposing daemon-managed keep-alive servers as one MCP bridge with readable `server__tool` names for stdio and Streamable HTTP clients. (PR #172, thanks @zm2231)
|
||||||
|
- Prefer MCP `structuredContent` nested inside JSON-RPC result envelopes so `mcporter call --output json` stays parseable for dual text/structured tool responses. (Issue #168, thanks @mar-zh)
|
||||||
|
- Serialize read-modify-write config and OAuth vault updates, and write JSON/cache metadata atomically to avoid lost entries under parallel invocations. (Issue #167, thanks @alexminza)
|
||||||
|
- Patch `chrome-devtools-mcp --autoConnect` launches at runtime so `mcporter call chrome-devtools.list_pages` can keep using a logged-in Chrome profile while upstream DevTools-window detection can hang on busy profiles.
|
||||||
|
|
||||||
|
### OAuth
|
||||||
|
|
||||||
|
- Add headless OAuth login support via `--no-browser`, `--browser none`, and `MCPORTER_OAUTH_NO_BROWSER`, emitting parseable authorization URLs for remote auth flows. (PR #171 / issue #169, thanks @feniix)
|
||||||
|
- Proactively complete OAuth for configured HTTP servers that allow unauthenticated `initialize`/`listTools` but require credentials for tool calls, and close the local callback server promptly after browser authorization. (PR #159, thanks @Spacefish)
|
||||||
|
- Refresh expired cached OAuth access tokens during non-interactive `mcporter list` without opening a browser or clearing cached credentials when refresh fails. (Issue #166, thanks @chrisabad)
|
||||||
|
|
||||||
## [0.10.2] - 2026-05-09
|
## [0.10.2] - 2026-05-09
|
||||||
|
|
||||||
|
|||||||
72
README.md
72
README.md
@ -21,16 +21,17 @@ 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|
||||||
## What's New in 0.10.0
|
## What's New in 0.11.0
|
||||||
|
|
||||||
- **Resources.** `mcporter resource <server> [uri]` lists and reads MCP resources, including keep-alive daemon routing.
|
- **Bridge mode.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP bridge with readable `server__tool` names.
|
||||||
- **Generated CLI polish.** Keep-alive generated CLIs preserve stdio server state across invocations, and Bun-compiled macOS daemon children launch reliably in the background.
|
- **Headless OAuth.** `--no-browser`, vault seeding, cached-token refresh, and `auth: "refreshable_bearer"` cover non-interactive deployments.
|
||||||
- **Config portability.** mcporter now honors XDG Base Directory env vars while preserving the legacy `~/.mcporter` fallback.
|
- **HTTP compatibility.** `httpFetch: "node-http1"` keeps providers that reject Node's built-in `fetch` working.
|
||||||
- **OAuth compatibility.** Static OAuth clients are supported via `oauthClientId`, `oauthClientSecretEnv`, and token endpoint auth method overrides.
|
- **Safer writes.** Config, OAuth vault, JSON output, and cache metadata writes are serialized/atomic so parallel agents stop stepping on each other.
|
||||||
- **Release confidence.** `0.10.0` is published on npm and Homebrew, and live/published install smokes are green.
|
- **Release confidence.** `0.11.0` is published on npm and Homebrew, and live/published install smokes are green.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@ -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 `--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.
|
- 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).
|
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
|
```bash
|
||||||
npx mcporter call chrome-devtools.take_snapshot
|
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: "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 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 shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
|
||||||
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
||||||
@ -161,10 +164,13 @@ 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).
|
- `--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.
|
- `--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).
|
- `--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 `--`.
|
- `--` (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.
|
- `--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.
|
- `--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.
|
- `--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.
|
||||||
- `--json` (on `mcporter emit-ts`) -- print a JSON summary describing the emitted files (mode + output paths) instead of text logs—handy when generating artifacts inside scripts.
|
- `--json` (on `mcporter emit-ts`) -- print a JSON summary describing the emitted files (mode + output paths) instead of text logs—handy when generating artifacts inside scripts.
|
||||||
- `--all-parameters` -- show every schema field when listing a server (default output shows at least five parameters plus a summary of the rest).
|
- `--all-parameters` -- show every schema field when listing a server (default output shows at least five parameters plus a summary of the rest).
|
||||||
- `--http-url <https://…>` / `--stdio "command …"` -- describe an ad-hoc MCP server inline. STDIO transports now inherit your current shell environment automatically; add `--env KEY=value` only when you need to inject/override variables alongside `--cwd`, `--name`, or `--persist <config.json>`. These flags now work with `mcporter auth` too, so `mcporter auth https://mcp.example.com/mcp` just works.
|
- `--http-url <https://…>` / `--stdio "command …"` -- describe an ad-hoc MCP server inline. STDIO transports now inherit your current shell environment automatically; add `--env KEY=value` only when you need to inject/override variables alongside `--cwd`, `--name`, or `--persist <config.json>`. These flags now work with `mcporter auth` too, so `mcporter auth https://mcp.example.com/mcp` just works.
|
||||||
@ -195,6 +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.
|
- 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.
|
- 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.
|
- The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon.
|
||||||
|
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/<server>` for one selected keep-alive server with its original, unprefixed tool names.
|
||||||
- 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.
|
- 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
|
## Friendlier Tool Calls
|
||||||
@ -249,7 +256,7 @@ const result = await callOnce({
|
|||||||
console.log(result); // raw MCP envelope
|
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
|
## Compose Automations with the Runtime
|
||||||
|
|
||||||
@ -391,7 +398,7 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y
|
|||||||
},
|
},
|
||||||
"chrome-devtools": {
|
"chrome-devtools": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
|
||||||
"env": { "npm_config_loglevel": "error" },
|
"env": { "npm_config_loglevel": "error" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -401,10 +408,11 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y
|
|||||||
|
|
||||||
What MCPorter handles for you:
|
What MCPorter handles for you:
|
||||||
|
|
||||||
- `${VAR}`, `${VAR:-fallback}`, and `$env:VAR` interpolation for headers and env entries.
|
- `${VAR}`, `${VAR:-fallback}`, and `$env:VAR` interpolation for config strings. Secret-bearing `headers`, `env`, and bearer-token placeholders stay lazy and resolve at runtime.
|
||||||
- Automatic OAuth token caching in the shared vault (`~/.mcporter/credentials.json`, or `$XDG_DATA_HOME/mcporter/credentials.json` when set) unless you override `tokenCacheDir`.
|
- Automatic OAuth token caching in the shared vault (`~/.mcporter/credentials.json`, or `$XDG_DATA_HOME/mcporter/credentials.json` when set) unless you override `tokenCacheDir`.
|
||||||
- Stdio commands inherit the directory of the file that defined them (imports or local config).
|
- Stdio commands inherit the directory of the file that defined them (imports or local config).
|
||||||
- Import precedence matches the array order; omit `imports` to use the default `["cursor", "claude-code", "claude-desktop", "codex", "windsurf", "opencode", "vscode"]`.
|
- Import precedence matches the array order; omit `imports` to use the default `["cursor", "claude-code", "claude-desktop", "codex", "windsurf", "opencode", "vscode"]`.
|
||||||
|
- `chrome-devtools-mcp --autoConnect` receives a small compatibility patch while upstream auto-connect can hang on busy Chrome profiles; set `MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT=1` to opt out.
|
||||||
|
|
||||||
#### OAuth-protected servers
|
#### OAuth-protected servers
|
||||||
|
|
||||||
@ -415,6 +423,8 @@ npx mcporter config add notion https://mcp.notion.com/mcp --auth oauth
|
|||||||
npx mcporter auth notion
|
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. 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:
|
Providers that do not support dynamic client registration can use a pre-registered app:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
@ -435,6 +445,44 @@ Providers that do not support dynamic client registration can use a pre-register
|
|||||||
Keep client secrets in environment variables or private machine-local configs,
|
Keep client secrets in environment variables or private machine-local configs,
|
||||||
and register the exact `oauthRedirectUrl` with the provider.
|
and register the exact `oauthRedirectUrl` with the provider.
|
||||||
|
|
||||||
|
#### Refreshable bearer tokens (non-interactive OAuth)
|
||||||
|
|
||||||
|
For servers with pre-seeded OAuth tokens that need automatic refresh without browser prompts, use `auth: "refreshable_bearer"`. HTTP servers receive `Authorization: Bearer <token>` headers; STDIO servers require `refresh.accessTokenEnv` to inject the token as an environment variable:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"example": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["example-mcp-server"],
|
||||||
|
"auth": "refreshable_bearer",
|
||||||
|
"refresh": {
|
||||||
|
"tokenEndpoint": "https://api.example.com/oauth/token",
|
||||||
|
"clientIdEnv": "EXAMPLE_CLIENT_ID",
|
||||||
|
"clientSecretEnv": "EXAMPLE_CLIENT_SECRET",
|
||||||
|
"clientAuthMethod": "client_secret_basic",
|
||||||
|
"refreshSkewSeconds": 300,
|
||||||
|
"accessTokenEnv": "EXAMPLE_ACCESS_TOKEN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
mcporter refreshes tokens before they expire (default 5 minutes early) using the refresh token from the vault. For keep-alive stdio servers that can't reload credentials after startup, use `"lifecycle": "ephemeral"` or restart the daemon before tokens expire.
|
||||||
|
|
||||||
|
Headless deployments that already have OAuth tokens can seed the vault without
|
||||||
|
reproducing mcporter's internal vault key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx mcporter vault set hubspot --tokens-file ./tokens.json
|
||||||
|
npx mcporter vault set hubspot --stdin < tokens.json
|
||||||
|
npx mcporter vault clear hubspot
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON payload is `{ "tokens": { ... }, "clientInfo": { ... } }`; `tokens`
|
||||||
|
is required and `clientInfo` is optional.
|
||||||
|
|
||||||
Provide `configPath` or `rootDir` to CLI/runtime calls when you juggle multiple config files side by side.
|
Provide `configPath` or `rootDir` to CLI/runtime calls when you juggle multiple config files side by side.
|
||||||
|
|
||||||
#### Config resolution order & system-level configs
|
#### Config resolution order & system-level configs
|
||||||
@ -444,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`).
|
1. The path you pass via `--config` (or programmatic `configPath`).
|
||||||
2. The `MCPORTER_CONFIG` environment variable (set it in your shell to apply everywhere).
|
2. The `MCPORTER_CONFIG` environment variable (set it in your shell to apply everywhere).
|
||||||
3. `<root>/config/mcporter.json` inside the current project.
|
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:
|
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:
|
||||||
|
|
||||||
@ -454,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).
|
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
|
### 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.
|
||||||
@ -3,7 +3,7 @@
|
|||||||
"chrome-devtools": {
|
"chrome-devtools": {
|
||||||
"description": "Chrome DevTools protocol bridge for driving local tabs during debugging or automation.",
|
"description": "Chrome DevTools protocol bridge for driving local tabs during debugging or automation.",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
|
||||||
"env": {
|
"env": {
|
||||||
"npm_config_loglevel": "error"
|
"npm_config_loglevel": "error"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,7 @@ Shipping a release means **all** of:
|
|||||||
After the release is live, always update the Homebrew tap and re-verify both installers. The tap formula should install the npm `.tgz`, not the Bun-compiled macOS tarball, because `generate-cli --compile` needs the installed package tree so Bun can resolve `mcporter`, `commander`, and related dependencies when compiling from an empty directory. Keep the macOS tarball on the GitHub release as a direct binary asset, but point Homebrew at `mcporter-<version>.tgz`.
|
After the release is live, always update the Homebrew tap and re-verify both installers. The tap formula should install the npm `.tgz`, not the Bun-compiled macOS tarball, because `generate-cli --compile` needs the installed package tree so Bun can resolve `mcporter`, `commander`, and related dependencies when compiling from an empty directory. Keep the macOS tarball on the GitHub release as a direct binary asset, but point Homebrew at `mcporter-<version>.tgz`.
|
||||||
|
|
||||||
1. Update `steipete/homebrew-tap` -> `Formula/mcporter.rb` with:
|
1. Update `steipete/homebrew-tap` -> `Formula/mcporter.rb` with:
|
||||||
- URL `https://github.com/steipete/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
|
- URL `https://github.com/openclaw/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
|
||||||
- SHA256 from `mcporter-<version>.tgz.sha256`
|
- SHA256 from `mcporter-<version>.tgz.sha256`
|
||||||
- `require "language/node"`, `depends_on "node"`, and `system "npm", "install", *std_npm_args, "--min-release-age=0"` so same-day releases with fresh npm dependencies can install immediately.
|
- `require "language/node"`, `depends_on "node"`, and `system "npm", "install", *std_npm_args, "--min-release-age=0"` so same-day releases with fresh npm dependencies can install immediately.
|
||||||
Refresh the tap README highlight so Homebrew users see the new version callout.
|
Refresh the tap README highlight so Homebrew users see the new version callout.
|
||||||
|
|||||||
@ -53,7 +53,7 @@ This name becomes the cache key for OAuth tokens and log preferences, so repeate
|
|||||||
|
|
||||||
Many hosted MCP servers (Supabase, Vercel, etc.) advertise OAuth capabilities but expect clients to discover this dynamically. When an ad-hoc HTTP server responds with `401/403` during the initial handshake, mcporter now:
|
Many hosted MCP servers (Supabase, Vercel, etc.) advertise OAuth capabilities but expect clients to discover this dynamically. When an ad-hoc HTTP server responds with `401/403` during the initial handshake, mcporter now:
|
||||||
|
|
||||||
1. **Promotes the definition to OAuth** and spins up the default browser flow—no need to edit config or supply `auth: "oauth"` manually.
|
1. **Promotes the definition to OAuth** and spins up the default browser flow—no need to edit config or supply `auth: "oauth"` manually. On headless hosts, pass `--no-browser` (or `--browser none`) to print the authorization URL instead of launching the platform browser.
|
||||||
2. **Persists the change** whenever you pass `--persist`, so future runs remember that the endpoint requires OAuth without repeating the detection step.
|
2. **Persists the change** whenever you pass `--persist`, so future runs remember that the endpoint requires OAuth without repeating the detection step.
|
||||||
|
|
||||||
The CLI still avoids surprise prompts during `mcporter list`; the upgrade happens the first time you run `mcporter auth <url>` or any other command that allows OAuth (i.e., not in `--autoAuthorize=false` mode).
|
The CLI still avoids surprise prompts during `mcporter list`; the upgrade happens the first time you run `mcporter auth <url>` or any other command that allows OAuth (i.e., not in `--autoAuthorize=false` mode).
|
||||||
@ -62,6 +62,8 @@ 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.
|
- 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.
|
- `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. 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.
|
- 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.
|
||||||
|
|
||||||
## Safety Nets
|
## Safety Nets
|
||||||
|
|||||||
@ -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 `=`.
|
- `--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.
|
- 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.
|
- `--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.
|
- 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.
|
- 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"`).
|
- `--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
|
- Add `--brief` or `--signatures` with a server or `server.tool` target to keep
|
||||||
the server header/instructions and print compact signatures without doc
|
the server header/instructions and print compact signatures without doc
|
||||||
comments, examples, or schemas.
|
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 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).
|
- Hidden ad-hoc flag aliases: `--sse` for `--http-url`, `--insecure` for `--allow-http` (for plain HTTP testing).
|
||||||
- Flags:
|
- Flags:
|
||||||
@ -29,7 +33,13 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `--signatures` – alias for `--brief`.
|
- `--signatures` – alias for `--brief`.
|
||||||
- `--all-parameters` – include every optional parameter in the signature.
|
- `--all-parameters` – include every optional parameter in the signature.
|
||||||
- `--schema` – pretty-print the JSON schema for each tool.
|
- `--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.
|
- `--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>`
|
## `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.
|
- `--save-images <dir>` – persist image content blocks to files under the specified directory.
|
||||||
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
||||||
- `--no-coerce` – disable all flag-style/positional value coercion.
|
- `--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.
|
- `--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]`
|
## `mcporter resource <server> [uri]`
|
||||||
|
|
||||||
@ -55,6 +68,30 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `--output auto|text|markdown|json|raw` – choose how to render the response.
|
- `--output auto|text|markdown|json|raw` – choose how to render the response.
|
||||||
- `--json` – shortcut for `--output json`.
|
- `--json` – shortcut for `--output json`.
|
||||||
- `--raw` – shortcut for `--output raw`.
|
- `--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>]`
|
||||||
|
|
||||||
|
- Exposes daemon-managed keep-alive servers as one MCP server for clients that
|
||||||
|
consume MCP over stdio or Streamable HTTP.
|
||||||
|
- `tools/list` queries the daemon for each selected server and publishes tools
|
||||||
|
as `server__tool`; `tools/call` strips the prefix and routes the call through
|
||||||
|
the daemon.
|
||||||
|
- In HTTP mode, `/mcp` keeps the aggregate namespaced bridge, while
|
||||||
|
`/mcp/<server>` exposes one selected keep-alive server with its original,
|
||||||
|
unprefixed tool names.
|
||||||
|
- Only configured keep-alive servers participate. Add
|
||||||
|
`"lifecycle": "keep-alive"` to a server definition when you want it managed
|
||||||
|
by the daemon.
|
||||||
|
- Flags:
|
||||||
|
- `--stdio` – serve MCP over stdio; this is the default and is the mode to
|
||||||
|
register with Claude Code, Codex, and similar clients.
|
||||||
|
- `--http <port>` – serve MCP Streamable HTTP on `/mcp` and
|
||||||
|
`/mcp/<server>`, bound to `127.0.0.1` by default.
|
||||||
|
- `--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.
|
||||||
|
|
||||||
## `mcporter generate-cli`
|
## `mcporter generate-cli`
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
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.
|
2. If `MCPORTER_CONFIG` is set, only that file is used—no merging.
|
||||||
3. Otherwise, mcporter loads both of these layers (when present):
|
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`
|
- `<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.
|
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/...` |
|
| cache | `XDG_CACHE_HOME` | `$XDG_CACHE_HOME/mcporter/<server>/schema.json` | `~/.mcporter/...` |
|
||||||
| state | `XDG_STATE_HOME` | `$XDG_STATE_HOME/mcporter/daemon/...` | `~/.mcporter/daemon` |
|
| 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
|
## Discovery & Precedence
|
||||||
|
|
||||||
@ -148,7 +148,8 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
|
|||||||
### `mcporter config login <name|url>` / `logout`
|
### `mcporter config login <name|url>` / `logout`
|
||||||
|
|
||||||
- Mirrors `mcporter auth`. `login` completes OAuth (or token provisioning) for either a named server or an ad-hoc URL. When a hosted MCP returns 401/403, mcporter automatically promotes that target to OAuth and re-runs the flow, matching the behavior documented in `docs/adhoc.md`.
|
- Mirrors `mcporter auth`. `login` completes OAuth (or token provisioning) for either a named server or an ad-hoc URL. When a hosted MCP returns 401/403, mcporter automatically promotes that target to OAuth and re-runs the flow, matching the behavior documented in `docs/adhoc.md`.
|
||||||
- `--browser none` suppresses automatic browser launch (useful for copying the URL into a remote browser).
|
- `--no-browser` suppresses automatic browser launch and prints the authorization URL to stdout so it can be copied from a headless host. `--browser none` is accepted as a compatibility alias, and `MCPORTER_OAUTH_NO_BROWSER=1` / `true` / `yes` enables the same behavior by environment.
|
||||||
|
- In `--json --no-browser` mode, stdout contains a JSON object with `authorizationUrl` and `redirectUrl`; diagnostics stay off stdout so scripts can parse the result. Treat emitted authorization URLs as sensitive operational output.
|
||||||
- `logout` wipes the shared vault entry, legacy `~/.mcporter/<name>/` caches, and the custom `tokenCacheDir` when present. Pass `--all` to clear everything.
|
- `logout` wipes the shared vault entry, legacy `~/.mcporter/<name>/` caches, and the custom `tokenCacheDir` when present. Pass `--all` to clear everything.
|
||||||
|
|
||||||
### `mcporter config doctor`
|
### `mcporter config doctor`
|
||||||
@ -165,6 +166,24 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
|
|||||||
- `--env KEY=VAL` entries merge with existing `env` dictionaries if you later persist the same server; nothing is lost when you alternate between CLI flags and JSON edits.
|
- `--env KEY=VAL` entries merge with existing `env` dictionaries if you later persist the same server; nothing is lost when you alternate between CLI flags and JSON edits.
|
||||||
- `--header KEY=VAL` entries merge into the persisted HTTP `headers` object when used with `--persist`; values support the same `$env:VAR`, `${VAR}`, and `${VAR:-fallback}` placeholders as config-file headers.
|
- `--header KEY=VAL` entries merge into the persisted HTTP `headers` object when used with `--persist`; values support the same `$env:VAR`, `${VAR}`, and `${VAR:-fallback}` placeholders as config-file headers.
|
||||||
|
|
||||||
|
## HTTP Compatibility
|
||||||
|
|
||||||
|
HTTP MCP servers normally use Node's built-in `fetch` through the upstream MCP SDK. If a provider rejects that stack but accepts plain Node `https.request` traffic, set `httpFetch: "node-http1"` on the server entry to force an HTTP/1.1 fetch implementation for Streamable HTTP and SSE POST requests:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"sunsama": {
|
||||||
|
"baseUrl": "https://api.sunsama.com/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${SUNSAMA_TOKEN}" },
|
||||||
|
"httpFetch": "node-http1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Sunsama endpoint is auto-detected and uses this compatibility path by default.
|
||||||
|
|
||||||
## JSON Schema for IDE Support
|
## JSON Schema for IDE Support
|
||||||
|
|
||||||
mcporter provides a JSON Schema for config file validation and autocompletion. Add the `$schema` property to your config file:
|
mcporter provides a JSON Schema for config file validation and autocompletion. Add the `$schema` property to your config file:
|
||||||
@ -206,7 +225,7 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
|
|||||||
| `cwd` | Working directory for stdio servers. A leading `~` is expanded to `$HOME`; relative paths resolve against the config file directory. Defaults to the config file directory when omitted. |
|
| `cwd` | Working directory for stdio servers. A leading `~` is expanded to `$HOME`; relative paths resolve against the config file directory. Defaults to the config file directory when omitted. |
|
||||||
| `env` | Key/value pairs applied when launching stdio commands. Supports `${VAR}` interpolation and `${VAR:-fallback}` defaults. Existing process env values win over fallbacks. |
|
| `env` | Key/value pairs applied when launching stdio commands. Supports `${VAR}` interpolation and `${VAR:-fallback}` defaults. Existing process env values win over fallbacks. |
|
||||||
| `headers` | Request headers for HTTP/SSE transports. Values can reference `$env:VAR` or `${VAR}` placeholders, which must be set at runtime or mcporter aborts with a helpful error. |
|
| `headers` | Request headers for HTTP/SSE transports. Values can reference `$env:VAR` or `${VAR}` placeholders, which must be set at runtime or mcporter aborts with a helpful error. |
|
||||||
| `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. `mcporter list` can still reuse an existing OAuth token cache for older HTTP entries missing this marker. |
|
| `auth` | Recognizes `oauth` and `refreshable_bearer`. `oauth` runs the browser/client OAuth flow for HTTP transports; `refreshable_bearer` refreshes cached bearer tokens non-interactively before connecting. |
|
||||||
| `tokenCacheDir` | Directory for OAuth tokens and schema caches; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` or `$XDG_DATA_HOME/mcporter/credentials.json` when set (legacy per-server caches are auto-migrated). Supports `~` expansion. |
|
| `tokenCacheDir` | Directory for OAuth tokens and schema caches; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` or `$XDG_DATA_HOME/mcporter/credentials.json` when set (legacy per-server caches are auto-migrated). Supports `~` expansion. |
|
||||||
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
|
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
|
||||||
| `oauthClientId` | Pre-registered OAuth client id for providers that do not support dynamic client registration. |
|
| `oauthClientId` | Pre-registered OAuth client id for providers that do not support dynamic client registration. |
|
||||||
@ -215,10 +234,40 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
|
|||||||
| `oauthRedirectUrl` | Override the default localhost callback. Required for many pre-registered OAuth apps because the provider must allowlist the exact redirect URI. Also useful when tunneling OAuth through Codespaces or remote dev boxes. |
|
| `oauthRedirectUrl` | Override the default localhost callback. Required for many pre-registered OAuth apps because the provider must allowlist the exact redirect URI. Also useful when tunneling OAuth through Codespaces or remote dev boxes. |
|
||||||
| `oauthScope` | Optional explicit OAuth scope string. If omitted, mcporter lets the MCP SDK derive scope from server/auth metadata. Use this as an escape hatch for providers that require explicit scopes but don’t publish `scopes_supported`. |
|
| `oauthScope` | Optional explicit OAuth scope string. If omitted, mcporter lets the MCP SDK derive scope from server/auth metadata. Use this as an escape hatch for providers that require explicit scopes but don’t publish `scopes_supported`. |
|
||||||
| `oauthCommand.args` | For STDIO servers that ship a custom auth subcommand (e.g., Gmail MCP). mcporter will spawn the stdio command with these args when you run `mcporter auth <name>`, so you don’t need to call `npx ... auth` manually. |
|
| `oauthCommand.args` | For STDIO servers that ship a custom auth subcommand (e.g., Gmail MCP). mcporter will spawn the stdio command with these args when you run `mcporter auth <name>`, so you don’t need to call `npx ... auth` manually. |
|
||||||
|
| `refresh` | Explicit token refresh settings for `auth: "refreshable_bearer"`. Supports `tokenEndpoint`, `clientIdEnv`, `clientSecretEnv`, `clientAuthMethod`, `refreshSkewSeconds`, and `accessTokenEnv` (plus snake_case aliases). |
|
||||||
| `allowedTools` / `allowed_tools` | Optional exact-name allowlist. Only listed tools appear in `mcporter list` and can be called. An empty array blocks all tools. Cannot be combined with `blockedTools`. |
|
| `allowedTools` / `allowed_tools` | Optional exact-name allowlist. Only listed tools appear in `mcporter list` and can be called. An empty array blocks all tools. Cannot be combined with `blockedTools`. |
|
||||||
| `blockedTools` / `blocked_tools` | Optional exact-name blocklist. Listed tools are hidden from `mcporter list` and rejected by `mcporter call`. Cannot be combined with `allowedTools`. |
|
| `blockedTools` / `blocked_tools` | Optional exact-name blocklist. Listed tools are hidden from `mcporter list` and rejected by `mcporter call`. Cannot be combined with `allowedTools`. |
|
||||||
|
|
||||||
mcporter normalizes headers to include `Accept: application/json, text/event-stream` automatically, matching the runtime’s streaming expectations.
|
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.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"example": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["example-mcp-server"],
|
||||||
|
"auth": "refreshable_bearer",
|
||||||
|
"refresh": {
|
||||||
|
"tokenEndpoint": "https://api.example.com/oauth/token",
|
||||||
|
"clientIdEnv": "EXAMPLE_CLIENT_ID",
|
||||||
|
"clientSecretEnv": "EXAMPLE_CLIENT_SECRET",
|
||||||
|
"clientAuthMethod": "client_secret_basic",
|
||||||
|
"refreshSkewSeconds": 300,
|
||||||
|
"accessTokenEnv": "EXAMPLE_ACCESS_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For keep-alive stdio servers, refresh happens before process start. If that process cannot read updated credentials after startup, use `lifecycle: "ephemeral"` or restart the daemon before the injected token expires.
|
||||||
|
|
||||||
## Imports & Conflict Resolution
|
## Imports & Conflict Resolution
|
||||||
|
|
||||||
@ -230,6 +279,7 @@ mcporter normalizes headers to include `Accept: application/json, text/event-str
|
|||||||
|
|
||||||
- Keep `config/mcporter.json` under version control. Encourage contributors to add sensitive data via env vars (`${LINEAR_API_KEY}`) rather than inline secrets.
|
- Keep `config/mcporter.json` under version control. Encourage contributors to add sensitive data via env vars (`${LINEAR_API_KEY}`) rather than inline secrets.
|
||||||
- For pre-registered OAuth apps, store the public `oauthClientId` in config and point `oauthClientSecretEnv` at a local environment variable. `oauthClientSecret` is supported for private machine-local configs but should not be committed.
|
- For pre-registered OAuth apps, store the public `oauthClientId` in config and point `oauthClientSecretEnv` at a local environment variable. `oauthClientSecret` is supported for private machine-local configs but should not be committed.
|
||||||
|
- For headless deployments that already have OAuth credentials, run `mcporter vault set <server> --tokens-file <path>` or `mcporter vault set <server> --stdin` with a JSON payload shaped like `{ "tokens": { ... }, "clientInfo": { ... } }`. This lets mcporter compute the vault key from the resolved server definition instead of duplicating that internal format in scripts.
|
||||||
- Machine-specific additions can live in `~/.mcporter/local.json` or `$XDG_CONFIG_HOME/mcporter/local.json`; point `mcporter config --config ~/.mcporter/local.json add ...` there when you prefer not to touch the repo. Since the runtime only watches one config at a time, CI jobs should always pass `--config config/mcporter.json` (or run from the repo root) for deterministic behavior.
|
- Machine-specific additions can live in `~/.mcporter/local.json` or `$XDG_CONFIG_HOME/mcporter/local.json`; point `mcporter config --config ~/.mcporter/local.json add ...` there when you prefer not to touch the repo. Since the runtime only watches one config at a time, CI jobs should always pass `--config config/mcporter.json` (or run from the repo root) for deterministic behavior.
|
||||||
- OAuth tokens, cached server metadata, and generated CLIs should remain outside the repo (`~/.mcporter/...` or the matching `XDG_*_HOME/mcporter/...`, plus `dist/`).
|
- OAuth tokens, cached server metadata, and generated CLIs should remain outside the repo (`~/.mcporter/...` or the matching `XDG_*_HOME/mcporter/...`, plus `dist/`).
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ read_when:
|
|||||||
|
|
||||||
## Goals
|
## 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.
|
- **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.
|
- **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.
|
- **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:**
|
- **Keep-alive detection:**
|
||||||
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
|
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
|
||||||
- Provide a config-level `defaultKeepAlive` array or `MCPORTER_KEEPALIVE` env var for quick overrides.
|
- 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
|
## 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).
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Agent Isolation
|
||||||
|
|||||||
@ -42,6 +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.
|
- **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.
|
- **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).
|
- **Ad-hoc connections + auto-OAuth.** Point the CLI at any MCP endpoint (HTTP, SSE, stdio) without touching config. Hosted MCPs that need a browser login (Supabase, Vercel, etc.) are auto-detected — `mcporter auth <url>` promotes the definition to OAuth on the fly. See [Ad-hoc connections](adhoc.md).
|
||||||
|
- **MCP bridge for agents.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP server with namespaced `server__tool` tools, or as per-server HTTP paths that keep original tool names.
|
||||||
- **OAuth & stdio ergonomics.** Built-in OAuth caching, token refresh, log tailing, and stdio wrappers — same interface across HTTP, SSE, and stdio transports.
|
- **OAuth & stdio ergonomics.** Built-in OAuth caching, token refresh, log tailing, and stdio wrappers — same interface across HTTP, SSE, and stdio transports.
|
||||||
|
|
||||||
## Built for agents
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
## Output schemas missing/buggy on many servers
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,7 @@ Add a logging block inside the server definition (alongside `lifecycle`) when yo
|
|||||||
"chrome-devtools": {
|
"chrome-devtools": {
|
||||||
"description": "Chrome DevTools protocol bridge",
|
"description": "Chrome DevTools protocol bridge",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
|
||||||
"lifecycle": "keep-alive",
|
"lifecycle": "keep-alive",
|
||||||
"logging": {
|
"logging": {
|
||||||
"daemon": { "enabled": true }
|
"daemon": { "enabled": true }
|
||||||
|
|||||||
@ -74,6 +74,13 @@ Expectations:
|
|||||||
- If a token cache exists, log should mention the cleared directory.
|
- If a token cache exists, log should mention the cleared directory.
|
||||||
- Failed auths emit the unified message (`Failed to authorize 'SERVER': ...`).
|
- Failed auths emit the unified message (`Failed to authorize 'SERVER': ...`).
|
||||||
|
|
||||||
|
For headless OAuth URL capture, run the same auth command with `--no-browser`:
|
||||||
|
|
||||||
|
- Text mode stdout should contain exactly one authorization URL line and no logger prefix.
|
||||||
|
- `--json --no-browser` stdout should parse as JSON with `authorizationUrl` and `redirectUrl`.
|
||||||
|
- If completing the flow from another machine over SSH, forward the printed callback port with a loopback-only tunnel; avoid exposing the callback listener publicly.
|
||||||
|
- Treat copied authorization URLs as sensitive and avoid storing them in long-lived logs.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
- To exercise error paths, point at a placeholder endpoint and use `--timeout 1000` (e.g., `https://example.com/mcp.listStuff`).
|
- To exercise error paths, point at a placeholder endpoint and use `--timeout 1000` (e.g., `https://example.com/mcp.listStuff`).
|
||||||
|
|||||||
@ -35,7 +35,7 @@ Use `createServerProxy(runtime, name)` inside scripts when you want ergonomic ca
|
|||||||
2. Automatically merges default values.
|
2. Automatically merges default values.
|
||||||
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
|
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
|
## Debug + Support Docs
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ This walkthrough assumes you already have an MCP server configured in Cursor, Cl
|
|||||||
npx mcporter list
|
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
|
## 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.
|
- `--all-parameters` — show every optional parameter inline.
|
||||||
- `--schema` — pretty-print the JSON schema for each tool.
|
- `--schema` — pretty-print the JSON schema for each tool.
|
||||||
- `--json` — machine-readable schema payload.
|
- `--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.
|
`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.
|
- 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`.
|
- 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.
|
- `--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 `--`.
|
- 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);
|
||||||
|
});
|
||||||
@ -64,7 +64,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"headers": {
|
"headers": {
|
||||||
"description": "HTTP headers for requests. Supports $VAR and $env:VAR placeholders",
|
"description": "HTTP headers for requests. Supports ${VAR}, ${VAR:-fallback}, and $env:VAR placeholders",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -74,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"description": "Environment variables for stdio commands. Supports $VAR and fallback syntax",
|
"description": "Environment variables for stdio commands. Supports ${VAR} and ${VAR:-fallback} placeholders",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -197,6 +197,75 @@
|
|||||||
"description": "Environment variable name containing the bearer token (snake_case)",
|
"description": "Environment variable name containing the bearer token (snake_case)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"refresh": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tokenEndpoint": {
|
||||||
|
"description": "OAuth token endpoint used to refresh access tokens",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"token_endpoint": {
|
||||||
|
"description": "OAuth token endpoint used to refresh access tokens",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"clientIdEnv": {
|
||||||
|
"description": "Environment variable containing the OAuth client id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"client_id_env": {
|
||||||
|
"description": "Environment variable containing the OAuth client id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"clientSecretEnv": {
|
||||||
|
"description": "Environment variable containing the OAuth client secret",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"client_secret_env": {
|
||||||
|
"description": "Environment variable containing the OAuth client secret",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"clientAuthMethod": {
|
||||||
|
"description": "OAuth token endpoint client auth method",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"client_auth_method": {
|
||||||
|
"description": "OAuth token endpoint client auth method",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"refreshSkewSeconds": {
|
||||||
|
"description": "Refresh before expiry by this many seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"refresh_skew_seconds": {
|
||||||
|
"description": "Refresh before expiry by this many seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"accessTokenEnv": {
|
||||||
|
"description": "STDIO env var that receives the refreshed access token",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"access_token_env": {
|
||||||
|
"description": "STDIO env var that receives the refreshed access token",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Refreshable bearer token settings"
|
||||||
|
},
|
||||||
|
"httpFetch": {
|
||||||
|
"description": "HTTP fetch implementation for Streamable HTTP/SSE requests",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["default", "node-http1"]
|
||||||
|
},
|
||||||
|
"http_fetch": {
|
||||||
|
"description": "HTTP fetch implementation for Streamable HTTP/SSE requests",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["default", "node-http1"]
|
||||||
|
},
|
||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@ -290,6 +359,18 @@
|
|||||||
},
|
},
|
||||||
"description": "Map of server names to their configurations"
|
"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": {
|
"imports": {
|
||||||
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
53
package.json
53
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcporter",
|
"name": "mcporter",
|
||||||
"version": "0.10.2",
|
"version": "0.12.1",
|
||||||
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cli",
|
"cli",
|
||||||
@ -12,7 +12,7 @@
|
|||||||
"author": "Sweetistics",
|
"author": "Sweetistics",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/steipete/mcporter.git"
|
"url": "git+https://github.com/openclaw/mcporter.git"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcporter": "dist/cli.js"
|
"mcporter": "dist/cli.js"
|
||||||
@ -61,36 +61,42 @@
|
|||||||
"docs:site": "node scripts/build-docs-site.mjs",
|
"docs:site": "node scripts/build-docs-site.mjs",
|
||||||
"generate:schema": "tsx scripts/generate-json-schema.ts",
|
"generate:schema": "tsx scripts/generate-json-schema.ts",
|
||||||
"mcporter:list": "pnpm exec tsx src/cli.ts list",
|
"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": {
|
"dependencies": {
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.17.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^15.0.0",
|
||||||
"es-toolkit": "^1.46.1",
|
"es-toolkit": "^1.48.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"ora": "^9.4.0",
|
"ora": "^9.4.1",
|
||||||
"rolldown": "1.0.0-rc.18",
|
"rolldown": "1.1.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.9",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^26.0.0",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260503.1",
|
"@typescript/native-preview": "7.0.0-dev.20260623.1",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"bun-types": "^1.3.13",
|
"bun-types": "^1.3.14",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"oxfmt": "^0.47.0",
|
"oxfmt": "^0.56.0",
|
||||||
"oxlint": "^1.62.0",
|
"oxlint": "^1.71.0",
|
||||||
"oxlint-tsgolint": "^0.22.1",
|
"oxlint-tsgolint": "^0.23.0",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "8.0.10",
|
"vite": "8.0.16",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
"runtime": [
|
"runtime": [
|
||||||
@ -103,12 +109,5 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2"
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"body-parser": "2.2.1",
|
|
||||||
"ip-address": "10.1.1",
|
|
||||||
"vite": "8.0.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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:
|
onlyBuiltDependencies:
|
||||||
- esbuild
|
- 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
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { css, faviconSvg, js, preThemeScript, themeToggleHtml } from './docs-sit
|
|||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const docsDir = path.join(root, 'docs');
|
const docsDir = path.join(root, 'docs');
|
||||||
const outDir = path.join(root, 'dist', 'docs-site');
|
const outDir = path.join(root, 'dist', 'docs-site');
|
||||||
const repoBase = 'https://github.com/steipete/mcporter';
|
const repoBase = 'https://github.com/openclaw/mcporter';
|
||||||
const repoEditBase = `${repoBase}/edit/main/docs`;
|
const repoEditBase = `${repoBase}/edit/main/docs`;
|
||||||
const cname = readCname();
|
const cname = readCname();
|
||||||
const siteBase = cname ? `https://${cname}` : '';
|
const siteBase = cname ? `https://${cname}` : '';
|
||||||
@ -103,8 +103,71 @@ copyStaticAsset('social-card.png');
|
|||||||
fs.writeFileSync(path.join(outDir, '.nojekyll'), '', 'utf8');
|
fs.writeFileSync(path.join(outDir, '.nojekyll'), '', 'utf8');
|
||||||
if (cname) fs.writeFileSync(path.join(outDir, 'CNAME'), cname, 'utf8');
|
if (cname) fs.writeFileSync(path.join(outDir, 'CNAME'), cname, 'utf8');
|
||||||
validateLinks(outDir);
|
validateLinks(outDir);
|
||||||
|
fs.writeFileSync(path.join(outDir, 'llms.txt'), llmsTxt(), 'utf8');
|
||||||
console.log(`built docs site: ${path.relative(root, outDir)}`);
|
console.log(`built docs site: ${path.relative(root, outDir)}`);
|
||||||
|
|
||||||
|
function llmsTxt() {
|
||||||
|
const origin = docsOrigin();
|
||||||
|
const source = docsSourceUrl();
|
||||||
|
const name = typeof productName !== 'undefined' ? productName : path.basename(root);
|
||||||
|
const description = typeof productDescription !== 'undefined' ? productDescription : `${name} documentation index.`;
|
||||||
|
const install = docsInstallHint();
|
||||||
|
const docPages = docsLlmsPages().map((page) => `- ${page.title}: ${pageUrl(origin, page.outRel)}`);
|
||||||
|
const lines = [`# ${name}`, '', description, '', 'Canonical documentation:', ...docPages];
|
||||||
|
if (install) {
|
||||||
|
lines.push('', 'Install:', `- ${install}`);
|
||||||
|
}
|
||||||
|
if (source) {
|
||||||
|
lines.push('', `Source: ${source}`);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'Guidance for agents:',
|
||||||
|
'- Prefer the canonical documentation URLs above over README excerpts or package metadata.',
|
||||||
|
'- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.'
|
||||||
|
);
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docsLlmsPages() {
|
||||||
|
const seen = new Set();
|
||||||
|
const ordered = typeof orderedPages !== 'undefined' ? orderedPages : [];
|
||||||
|
return [...ordered, ...pages].filter((page) => page.outRel && !seen.has(page.outRel) && seen.add(page.outRel));
|
||||||
|
}
|
||||||
|
|
||||||
|
function docsOrigin() {
|
||||||
|
const value =
|
||||||
|
(typeof siteBase !== 'undefined' && siteBase) ||
|
||||||
|
(typeof siteUrl !== 'undefined' && siteUrl) ||
|
||||||
|
(typeof customDomain !== 'undefined' && customDomain ? `https://${customDomain}` : '');
|
||||||
|
return value.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function docsSourceUrl() {
|
||||||
|
if (typeof repoBase !== 'undefined') return repoBase;
|
||||||
|
if (typeof repoUrl !== 'undefined') return repoUrl;
|
||||||
|
if (typeof repoEditBase !== 'undefined') return repoEditBase.replace(/\/edit\/main\/docs\/?$/, '');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function docsInstallHint() {
|
||||||
|
if (typeof installCommand !== 'undefined') return installCommand;
|
||||||
|
if (typeof installLine !== 'undefined') return installLine;
|
||||||
|
if (typeof installCmd !== 'undefined') return installCmd;
|
||||||
|
if (typeof installSnippet !== 'undefined') return installSnippet;
|
||||||
|
if (typeof brewInstall !== 'undefined') return brewInstall;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageUrl(origin, outRel) {
|
||||||
|
const normalized =
|
||||||
|
outRel === 'index.html'
|
||||||
|
? ''
|
||||||
|
: outRel.replace(/(?:^|\/)index\.html$/, (match) => (match === 'index.html' ? '' : '/'));
|
||||||
|
if (!origin) return normalized || 'index.html';
|
||||||
|
return normalized ? `${origin}/${normalized}` : `${origin}/`;
|
||||||
|
}
|
||||||
|
|
||||||
function readCname() {
|
function readCname() {
|
||||||
for (const candidate of [path.join(docsDir, 'CNAME'), path.join(root, 'CNAME')]) {
|
for (const candidate of [path.join(docsDir, 'CNAME'), path.join(root, 'CNAME')]) {
|
||||||
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8').trim();
|
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8').trim();
|
||||||
|
|||||||
@ -129,6 +129,12 @@ body:not(.home) .doc>h1:first-child{display:none}
|
|||||||
.doc pre::-webkit-scrollbar{height:8px;width:8px}
|
.doc pre::-webkit-scrollbar{height:8px;width:8px}
|
||||||
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
|
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
|
||||||
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
|
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
|
||||||
|
.doc pre code .tok-comment{color:#6b7280;font-style:italic}
|
||||||
|
.doc pre code .tok-cmd{color:#67e8f9;font-weight:600}
|
||||||
|
.doc pre code .tok-flag{color:#c4b5fd}
|
||||||
|
.doc pre code .tok-string{color:#fde68a}
|
||||||
|
.doc pre code .tok-key{color:#93c5fd}
|
||||||
|
.doc pre code .tok-value{color:#86efac}
|
||||||
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
|
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
|
||||||
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
|
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
|
||||||
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
|
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
|
||||||
@ -238,7 +244,13 @@ if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarFor
|
|||||||
else mobileNav.addListener?.(syncSidebarForViewport);
|
else mobileNav.addListener?.(syncSidebarForViewport);
|
||||||
const input=document.getElementById('doc-search');
|
const input=document.getElementById('doc-search');
|
||||||
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
|
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
|
||||||
|
function escapeHtmlText(value){return value.replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]))}
|
||||||
|
const shellCommands=new Set(['bun','mcporter','node','npm','npx','pnpm','list','call','resource','generate-cli','emit-ts','auth','config','daemon','inspect-cli']);
|
||||||
|
function span(cls,text){return '<span class="'+cls+'">'+escapeHtmlText(text)+'</span>'}
|
||||||
|
function highlightShellLine(line){if(/^\\s*#/.test(line))return span('tok-comment',line);let html='';let i=0;while(i<line.length){const ch=line[i];if(/\\s/.test(ch)){html+=ch;i++;continue}if(ch==='#'){html+=span('tok-comment',line.slice(i));break}if(ch==="'"||ch==='"'){const quote=ch;let j=i+1;while(j<line.length){if(line[j]==='\\\\'){j+=2;continue}if(line[j]===quote){j++;break}j++}html+=span('tok-string',line.slice(i,j));i=j;continue}let j=i;while(j<line.length&&!/\\s/.test(line[j])&&line[j]!=="'"&&line[j]!=='"'&&line[j]!=='#')j++;const token=line.slice(i,j);if(token.startsWith('--')||/^-[A-Za-z]/.test(token))html+=span('tok-flag',token);else if(shellCommands.has(token))html+=span('tok-cmd',token);else if(/^[A-Za-z][A-Za-z0-9_-]*:/.test(token)){const idx=token.indexOf(':')+1;html+=span('tok-key',token.slice(0,idx))+span('tok-value',token.slice(idx))}else html+=escapeHtmlText(token);i=j}return html}
|
||||||
|
function highlightCodeBlocks(){document.querySelectorAll('.doc pre code.language-bash,.doc pre code.language-sh,.doc pre code.language-shell').forEach(code=>{if(code.dataset.highlighted)return;code.dataset.highlighted='true';code.innerHTML=code.textContent.split('\\n').map(highlightShellLine).join('\\n')})}
|
||||||
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
|
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
|
||||||
|
highlightCodeBlocks();
|
||||||
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
|
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
|
||||||
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
|
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
|
||||||
const tocLinks=document.querySelectorAll('.toc a');
|
const tocLinks=document.querySelectorAll('.toc a');
|
||||||
|
|||||||
72
src/chrome-devtools-auto-connect-patch.ts
Normal file
72
src/chrome-devtools-auto-connect-patch.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const MARKER = 'MCPORTER_DEVTOOLS_TIMEOUT_PATCH';
|
||||||
|
const HELPER = `// ${MARKER}
|
||||||
|
const MCPORTER_DEVTOOLS_DETECTION_TIMEOUT = 1_000;
|
||||||
|
async function mcporterWithTimeout(promise, fallback) {
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise(resolve => {
|
||||||
|
timer = setTimeout(resolve, MCPORTER_DEVTOOLS_DETECTION_TIMEOUT, fallback);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DETECTION_BLOCK = `if (await page.hasDevTools()) {
|
||||||
|
mcpPage.devToolsPage = await page.openDevTools();
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const PATCHED_DETECTION_BLOCK = `if (await mcporterWithTimeout(page.hasDevTools(), false)) {
|
||||||
|
mcpPage.devToolsPage = await mcporterWithTimeout(page.openDevTools(), undefined);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
patchChromeDevtoolsMcp();
|
||||||
|
|
||||||
|
export function patchChromeDevtoolsMcp(mainPath = process.argv[1]): void {
|
||||||
|
if (!mainPath || !mainPath.includes('chrome-devtools-mcp')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let resolvedMainPath: string;
|
||||||
|
try {
|
||||||
|
resolvedMainPath = fs.realpathSync(mainPath);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resolvedMainPath.endsWith(path.join('bin', 'chrome-devtools-mcp.js'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const contextPath = path.resolve(path.dirname(resolvedMainPath), '..', 'McpContext.js');
|
||||||
|
let source: string;
|
||||||
|
try {
|
||||||
|
source = fs.readFileSync(contextPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source.includes(MARKER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!source.includes(DETECTION_BLOCK)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const withHelper = source.replace(
|
||||||
|
'const NAVIGATION_TIMEOUT = 10_000;\n',
|
||||||
|
`const NAVIGATION_TIMEOUT = 10_000;\n${HELPER}`
|
||||||
|
);
|
||||||
|
const patched = withHelper.replace(DETECTION_BLOCK, PATCHED_DETECTION_BLOCK);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(contextPath, patched);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/chrome-devtools-compat.ts
Normal file
162
src/chrome-devtools-compat.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const AUTO_CONNECT_FLAGS = new Set(['--autoConnect', '--auto-connect']);
|
||||||
|
const FALLBACK_PATCH_FILENAME = 'mcporter-chrome-devtools-auto-connect-patch.js';
|
||||||
|
const FALLBACK_PATCH_SOURCE = `import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const MARKER = 'MCPORTER_DEVTOOLS_TIMEOUT_PATCH';
|
||||||
|
const HELPER = \`// \${MARKER}
|
||||||
|
const MCPORTER_DEVTOOLS_DETECTION_TIMEOUT = 1_000;
|
||||||
|
async function mcporterWithTimeout(promise, fallback) {
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise(resolve => {
|
||||||
|
timer = setTimeout(resolve, MCPORTER_DEVTOOLS_DETECTION_TIMEOUT, fallback);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`;
|
||||||
|
|
||||||
|
const DETECTION_BLOCK = \`if (await page.hasDevTools()) {
|
||||||
|
mcpPage.devToolsPage = await page.openDevTools();
|
||||||
|
}\`;
|
||||||
|
|
||||||
|
const PATCHED_DETECTION_BLOCK = \`if (await mcporterWithTimeout(page.hasDevTools(), false)) {
|
||||||
|
mcpPage.devToolsPage = await mcporterWithTimeout(page.openDevTools(), undefined);
|
||||||
|
}\`;
|
||||||
|
|
||||||
|
patchChromeDevtoolsMcp();
|
||||||
|
|
||||||
|
function patchChromeDevtoolsMcp(mainPath = process.argv[1]) {
|
||||||
|
if (!mainPath || !mainPath.includes('chrome-devtools-mcp')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let resolvedMainPath;
|
||||||
|
try {
|
||||||
|
resolvedMainPath = fs.realpathSync(mainPath);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resolvedMainPath.endsWith(path.join('bin', 'chrome-devtools-mcp.js'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const contextPath = path.resolve(path.dirname(resolvedMainPath), '..', 'McpContext.js');
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
source = fs.readFileSync(contextPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source.includes(MARKER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!source.includes(DETECTION_BLOCK)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const withHelper = source.replace(
|
||||||
|
'const NAVIGATION_TIMEOUT = 10_000;\\n',
|
||||||
|
\`const NAVIGATION_TIMEOUT = 10_000;\\n\${HELPER}\`
|
||||||
|
);
|
||||||
|
const patched = withHelper.replace(DETECTION_BLOCK, PATCHED_DETECTION_BLOCK);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(contextPath, patched);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface ChromeDevtoolsCompatResult {
|
||||||
|
readonly env: Record<string, string>;
|
||||||
|
readonly applied: boolean;
|
||||||
|
readonly patchPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyChromeDevtoolsCompat(
|
||||||
|
env: Record<string, string>,
|
||||||
|
command: string,
|
||||||
|
args: readonly string[]
|
||||||
|
): ChromeDevtoolsCompatResult {
|
||||||
|
if (!shouldApplyChromeDevtoolsCompat(command, args, env)) {
|
||||||
|
return { env, applied: false };
|
||||||
|
}
|
||||||
|
const patchPath = resolveChromeDevtoolsCompatPatchPath();
|
||||||
|
if (!patchPath) {
|
||||||
|
return { env, applied: false };
|
||||||
|
}
|
||||||
|
const importFlag = `--import=${pathToFileURL(patchPath).href}`;
|
||||||
|
const existingOptions = env.NODE_OPTIONS?.trim();
|
||||||
|
if (existingOptions?.includes(importFlag)) {
|
||||||
|
return { env, applied: true, patchPath };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
NODE_OPTIONS: existingOptions ? `${existingOptions} ${importFlag}` : importFlag,
|
||||||
|
},
|
||||||
|
applied: true,
|
||||||
|
patchPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldApplyChromeDevtoolsCompat(
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
env: NodeJS.ProcessEnv | Record<string, string> = process.env
|
||||||
|
): boolean {
|
||||||
|
if (env.MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT === '1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tokens = [command, ...args];
|
||||||
|
return tokens.some(isChromeDevtoolsToken) && args.some((arg) => AUTO_CONNECT_FLAGS.has(arg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChromeDevtoolsToken(token: string): boolean {
|
||||||
|
return (
|
||||||
|
token === 'chrome-devtools-mcp' ||
|
||||||
|
token.startsWith('chrome-devtools-mcp@') ||
|
||||||
|
token.includes('/chrome-devtools-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChromeDevtoolsCompatPatchPath(
|
||||||
|
candidates = defaultChromeDevtoolsPatchCandidates(),
|
||||||
|
fallbackDir = os.tmpdir()
|
||||||
|
): string | undefined {
|
||||||
|
const existing = candidates.find((candidate) => fs.existsSync(candidate));
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return writeFallbackPatch(fallbackDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultChromeDevtoolsPatchCandidates(): string[] {
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
return [
|
||||||
|
path.join(here, 'chrome-devtools-auto-connect-patch.js'),
|
||||||
|
path.resolve(here, '..', 'dist', 'chrome-devtools-auto-connect-patch.js'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFallbackPatch(fallbackDir: string): string | undefined {
|
||||||
|
const patchPath = path.join(fallbackDir, FALLBACK_PATCH_FILENAME);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(patchPath, FALLBACK_PATCH_SOURCE, { mode: 0o600 });
|
||||||
|
return patchPath;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,8 @@ export interface SerializedServerDefinition {
|
|||||||
readonly oauthTokenEndpointAuthMethod?: string;
|
readonly oauthTokenEndpointAuthMethod?: string;
|
||||||
readonly oauthRedirectUrl?: string;
|
readonly oauthRedirectUrl?: string;
|
||||||
readonly oauthScope?: string;
|
readonly oauthScope?: string;
|
||||||
|
readonly refresh?: ServerDefinition['refresh'];
|
||||||
|
readonly httpFetch?: ServerDefinition['httpFetch'];
|
||||||
readonly allowedTools?: readonly string[];
|
readonly allowedTools?: readonly string[];
|
||||||
readonly blockedTools?: readonly string[];
|
readonly blockedTools?: readonly string[];
|
||||||
}
|
}
|
||||||
@ -72,16 +74,26 @@ export function metadataPathForArtifact(artifactPath: string): string {
|
|||||||
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
|
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
|
||||||
// inspect command and falling back to legacy sidecar files.
|
// inspect command and falling back to legacy sidecar files.
|
||||||
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
|
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||||
|
let embeddedError: unknown;
|
||||||
|
try {
|
||||||
|
return await readMetadataFromCli(artifactPath);
|
||||||
|
} catch (error) {
|
||||||
|
embeddedError = error;
|
||||||
|
}
|
||||||
|
|
||||||
const legacyPath = metadataPathForArtifact(artifactPath);
|
const legacyPath = metadataPathForArtifact(artifactPath);
|
||||||
try {
|
try {
|
||||||
const buffer = await fs.readFile(legacyPath, 'utf8');
|
const buffer = await fs.readFile(legacyPath, 'utf8');
|
||||||
return JSON.parse(buffer) as CliArtifactMetadata;
|
return JSON.parse(buffer) as CliArtifactMetadata;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isErrno(error, 'ENOENT') && embeddedError) {
|
||||||
|
throw embeddedError;
|
||||||
|
}
|
||||||
if (!isErrno(error, 'ENOENT')) {
|
if (!isErrno(error, 'ENOENT')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return await readMetadataFromCli(artifactPath);
|
throw embeddedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {
|
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||||
@ -151,6 +163,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
|||||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||||
oauthScope: definition.oauthScope,
|
oauthScope: definition.oauthScope,
|
||||||
|
refresh: definition.refresh,
|
||||||
|
httpFetch: definition.httpFetch,
|
||||||
allowedTools: definition.allowedTools,
|
allowedTools: definition.allowedTools,
|
||||||
blockedTools: definition.blockedTools,
|
blockedTools: definition.blockedTools,
|
||||||
};
|
};
|
||||||
@ -173,6 +187,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
|||||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||||
oauthScope: definition.oauthScope,
|
oauthScope: definition.oauthScope,
|
||||||
|
refresh: definition.refresh,
|
||||||
|
httpFetch: definition.httpFetch,
|
||||||
allowedTools: definition.allowedTools,
|
allowedTools: definition.allowedTools,
|
||||||
blockedTools: definition.blockedTools,
|
blockedTools: definition.blockedTools,
|
||||||
};
|
};
|
||||||
|
|||||||
206
src/cli.ts
206
src/cli.ts
@ -4,6 +4,7 @@ import { inferCommandRouting } from './cli/command-inference.js';
|
|||||||
import { CliUsageError } from './cli/errors.js';
|
import { CliUsageError } from './cli/errors.js';
|
||||||
import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js';
|
import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js';
|
||||||
import { logError, logInfo } from './cli/logger-context.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 { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
|
||||||
import { resolveConfigPath } from './config/path-discovery.js';
|
import { resolveConfigPath } from './config/path-discovery.js';
|
||||||
import type { Runtime, RuntimeOptions } from './runtime.js';
|
import type { Runtime, RuntimeOptions } from './runtime.js';
|
||||||
@ -139,6 +140,43 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'serve') {
|
||||||
|
const { handleServeCli, printServeHelp } = await import('./cli/serve-command.js');
|
||||||
|
if (consumeHelpTokens(args)) {
|
||||||
|
printServeHelp();
|
||||||
|
process.exitCode = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleServeCli(args, {
|
||||||
|
configPath: configPathResolved,
|
||||||
|
configExplicit: configResolution.explicit,
|
||||||
|
rootDir: rootOverride,
|
||||||
|
});
|
||||||
|
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') {
|
if (command === 'config') {
|
||||||
const { handleConfigCli } = await import('./cli/config-command.js');
|
const { handleConfigCli } = await import('./cli/config-command.js');
|
||||||
await handleConfigCli(
|
await handleConfigCli(
|
||||||
@ -182,14 +220,17 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
import('./lifecycle.js'),
|
import('./lifecycle.js'),
|
||||||
]);
|
]);
|
||||||
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
|
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
|
||||||
const keepAliveServers = new Set(
|
const recordReplayModeActive = isRecordReplayModeActive();
|
||||||
baseRuntime
|
const keepAliveServers = recordReplayModeActive
|
||||||
.getDefinitions()
|
? new Set<string>()
|
||||||
.filter(isKeepAliveServer)
|
: new Set(
|
||||||
.map((entry) => entry.name)
|
baseRuntime
|
||||||
);
|
.getDefinitions()
|
||||||
|
.filter(isKeepAliveServer)
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
);
|
||||||
const daemonClient =
|
const daemonClient =
|
||||||
keepAliveServers.size > 0
|
!recordReplayModeActive && keepAliveServers.size > 0
|
||||||
? new DaemonClient({
|
? new DaemonClient({
|
||||||
configPath: configResolution.path,
|
configPath: configResolution.path,
|
||||||
configExplicit: configResolution.explicit,
|
configExplicit: configResolution.explicit,
|
||||||
@ -198,15 +239,16 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
: null;
|
: null;
|
||||||
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
|
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
|
||||||
|
|
||||||
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
let primaryError: unknown;
|
||||||
if (inference.kind === 'abort') {
|
|
||||||
process.exitCode = inference.exitCode;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolvedCommand = inference.command;
|
|
||||||
const resolvedArgs = inference.args;
|
|
||||||
|
|
||||||
try {
|
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 (resolvedCommand === 'list') {
|
||||||
if (consumeHelpTokens(resolvedArgs)) {
|
if (consumeHelpTokens(resolvedArgs)) {
|
||||||
const { printListHelp } = await import('./cli/list-command.js');
|
const { printListHelp } = await import('./cli/list-command.js');
|
||||||
@ -243,6 +285,18 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolvedCommand === 'vault') {
|
||||||
|
if (consumeHelpTokens(resolvedArgs)) {
|
||||||
|
const { printVaultHelp } = await import('./cli/vault-command.js');
|
||||||
|
printVaultHelp();
|
||||||
|
process.exitCode = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { handleVault } = await import('./cli/vault-command.js');
|
||||||
|
await handleVault(runtime, resolvedArgs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (resolvedCommand === 'resource' || resolvedCommand === 'resources') {
|
if (resolvedCommand === 'resource' || resolvedCommand === 'resources') {
|
||||||
if (consumeHelpTokens(resolvedArgs)) {
|
if (consumeHelpTokens(resolvedArgs)) {
|
||||||
const { printResourceHelp } = await import('./cli/resource-command.js');
|
const { printResourceHelp } = await import('./cli/resource-command.js');
|
||||||
@ -254,46 +308,69 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
await importedHandleResource(runtime, resolvedArgs);
|
await importedHandleResource(runtime, resolvedArgs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printHelp(`Unknown command '${resolvedCommand}'.`);
|
||||||
|
process.exit(1);
|
||||||
|
} catch (error) {
|
||||||
|
primaryError = error;
|
||||||
|
throw error;
|
||||||
} finally {
|
} 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) {
|
if (DEBUG_HANG) {
|
||||||
logInfo('[debug] beginning runtime.close()');
|
const duration = Date.now() - closeStart;
|
||||||
dumpActiveHandles('before runtime.close');
|
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
|
||||||
|
dumpActiveHandles('after runtime.close');
|
||||||
}
|
}
|
||||||
try {
|
} catch (error) {
|
||||||
await runtime.close();
|
if (DEBUG_HANG) {
|
||||||
if (DEBUG_HANG) {
|
logError('[debug] runtime.close() failed', error);
|
||||||
const duration = Date.now() - closeStart;
|
}
|
||||||
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
|
if (isReplayModeActive() && !options.suppressReplayCloseError) {
|
||||||
dumpActiveHandles('after runtime.close');
|
closeError = error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} finally {
|
||||||
if (DEBUG_HANG) {
|
terminateChildProcesses('runtime.finally');
|
||||||
logError('[debug] runtime.close() failed', error);
|
// 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.
|
||||||
} finally {
|
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
|
||||||
terminateChildProcesses('runtime.finally');
|
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
|
||||||
// By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles
|
const scheduleForcedExit = () => {
|
||||||
// (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1.
|
if (shouldForceExit) {
|
||||||
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
|
setTimeout(() => {
|
||||||
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
|
process.exit(process.exitCode ?? 0);
|
||||||
const scheduleForcedExit = () => {
|
}, FORCE_EXIT_GRACE_MS);
|
||||||
if (shouldForceExit) {
|
|
||||||
setTimeout(() => {
|
|
||||||
process.exit(process.exitCode ?? 0);
|
|
||||||
}, FORCE_EXIT_GRACE_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (DEBUG_HANG) {
|
|
||||||
dumpActiveHandles('after terminateChildProcesses');
|
|
||||||
scheduleForcedExit();
|
|
||||||
} else {
|
|
||||||
setImmediate(scheduleForcedExit);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if (DEBUG_HANG) {
|
||||||
|
dumpActiveHandles('after terminateChildProcesses');
|
||||||
|
scheduleForcedExit();
|
||||||
|
} else {
|
||||||
|
setImmediate(scheduleForcedExit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
printHelp(`Unknown command '${resolvedCommand}'.`);
|
if (closeError) {
|
||||||
process.exit(1);
|
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.
|
// main parses CLI flags and dispatches to list/call commands.
|
||||||
@ -333,6 +410,9 @@ async function maybeHandleDaemonFastCall(
|
|||||||
configResolution: { path: string; explicit: boolean },
|
configResolution: { path: string; explicit: boolean },
|
||||||
rootDir: string | undefined
|
rootDir: string | undefined
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (isRecordReplayModeActive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const callArgs = resolveDaemonFastCallArgs(command, args);
|
const callArgs = resolveDaemonFastCallArgs(command, args);
|
||||||
if (!callArgs) {
|
if (!callArgs) {
|
||||||
return false;
|
return false;
|
||||||
@ -400,6 +480,7 @@ async function maybeHandleSimpleDaemonFastCall(
|
|||||||
tool: parsed.tool,
|
tool: parsed.tool,
|
||||||
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
|
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
|
||||||
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
|
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
|
||||||
|
disableOAuth: parsed.disableOAuth,
|
||||||
});
|
});
|
||||||
const { callResult } = wrapCallResult(result);
|
const { callResult } = wrapCallResult(result);
|
||||||
printCallOutput(callResult, result, parsed.output);
|
printCallOutput(callResult, result, parsed.output);
|
||||||
@ -426,6 +507,9 @@ function isExplicitNonCallCommand(command: string): boolean {
|
|||||||
command === 'resource' ||
|
command === 'resource' ||
|
||||||
command === 'resources' ||
|
command === 'resources' ||
|
||||||
command === 'daemon' ||
|
command === 'daemon' ||
|
||||||
|
command === 'serve' ||
|
||||||
|
command === 'record' ||
|
||||||
|
command === 'replay' ||
|
||||||
command === 'config' ||
|
command === 'config' ||
|
||||||
command === 'emit-ts' ||
|
command === 'emit-ts' ||
|
||||||
command === 'generate-cli' ||
|
command === 'generate-cli' ||
|
||||||
@ -501,6 +585,8 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
|
|||||||
server,
|
server,
|
||||||
includeSchema: options?.includeSchema,
|
includeSchema: options?.includeSchema,
|
||||||
autoAuthorize: options?.autoAuthorize,
|
autoAuthorize: options?.autoAuthorize,
|
||||||
|
allowCachedAuth: options?.allowCachedAuth,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
})) as Awaited<ReturnType<Runtime['listTools']>>,
|
})) as Awaited<ReturnType<Runtime['listTools']>>,
|
||||||
callTool: (server, toolName, options) =>
|
callTool: (server, toolName, options) =>
|
||||||
daemonClient.callTool({
|
daemonClient.callTool({
|
||||||
@ -508,9 +594,27 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
|
|||||||
tool: toolName,
|
tool: toolName,
|
||||||
args: options?.args,
|
args: options?.args,
|
||||||
timeoutMs: options?.timeoutMs,
|
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) => {
|
connect: async (server) => {
|
||||||
throw new Error(`Server '${server}' is only available through daemon request methods.`);
|
throw new Error(`Server '${server}' is only available through daemon request methods.`);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import path from 'node:path';
|
|||||||
import type { CommandSpec, ServerDefinition } from '../config.js';
|
import type { CommandSpec, ServerDefinition } from '../config.js';
|
||||||
import { __configInternals } from '../config.js';
|
import { __configInternals } from '../config.js';
|
||||||
import { expandHome } from '../env.js';
|
import { expandHome } from '../env.js';
|
||||||
|
import { withFileLock, writeTextFileAtomic } from '../fs-json.js';
|
||||||
import { canonicalKeepAliveName, resolveLifecycle } from '../lifecycle.js';
|
import { canonicalKeepAliveName, resolveLifecycle } from '../lifecycle.js';
|
||||||
|
|
||||||
export interface EphemeralServerSpec {
|
export interface EphemeralServerSpec {
|
||||||
@ -48,7 +49,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
|||||||
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
|
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
|
||||||
};
|
};
|
||||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
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 lifecycle = resolveLifecycle(name, undefined, command);
|
||||||
const definition: ServerDefinition = {
|
const definition: ServerDefinition = {
|
||||||
name,
|
name,
|
||||||
@ -83,7 +84,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
|||||||
cwd,
|
cwd,
|
||||||
};
|
};
|
||||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
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 lifecycle = resolveLifecycle(name, undefined, command);
|
||||||
const definition: ServerDefinition = {
|
const definition: ServerDefinition = {
|
||||||
name,
|
name,
|
||||||
@ -108,26 +109,27 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
|||||||
|
|
||||||
export async function persistEphemeralServer(resolution: EphemeralServerResolution, rawPath: string): Promise<void> {
|
export async function persistEphemeralServer(resolution: EphemeralServerResolution, rawPath: string): Promise<void> {
|
||||||
const resolvedPath = path.resolve(expandHome(rawPath));
|
const resolvedPath = path.resolve(expandHome(rawPath));
|
||||||
let existing: Record<string, unknown>;
|
await withFileLock(resolvedPath, async () => {
|
||||||
try {
|
let existing: Record<string, unknown>;
|
||||||
const buffer = await fs.readFile(resolvedPath, 'utf8');
|
try {
|
||||||
existing = JSON.parse(buffer) as Record<string, unknown>;
|
const buffer = await fs.readFile(resolvedPath, 'utf8');
|
||||||
} catch (error) {
|
existing = JSON.parse(buffer) as Record<string, unknown>;
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
} catch (error) {
|
||||||
throw error;
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
existing = { mcpServers: {} };
|
||||||
}
|
}
|
||||||
existing = { mcpServers: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) {
|
if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) {
|
||||||
existing.mcpServers = {};
|
existing.mcpServers = {};
|
||||||
}
|
}
|
||||||
const servers = existing.mcpServers as Record<string, unknown>;
|
const servers = existing.mcpServers as Record<string, unknown>;
|
||||||
servers[resolution.name] = resolution.persistedEntry;
|
servers[resolution.name] = resolution.persistedEntry;
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
const serialized = `${JSON.stringify(existing, null, 2)}\n`;
|
||||||
const serialized = `${JSON.stringify(existing, null, 2)}\n`;
|
await writeTextFileAtomic(resolvedPath, serialized);
|
||||||
await fs.writeFile(resolvedPath, serialized, 'utf8');
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferNameFromUrl(url: URL): string {
|
function inferNameFromUrl(url: URL): string {
|
||||||
@ -204,6 +206,14 @@ function slugify(value: string): string {
|
|||||||
.replace(/-{2,}/g, '-');
|
.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[] {
|
export function splitCommandLine(input: string): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { ServerDefinition } from '../config-schema.js';
|
import type { ServerDefinition } from '../config-schema.js';
|
||||||
|
import type { OAuthAuthorizationRequest, OAuthSessionOptions } from '../oauth.js';
|
||||||
import { analyzeConnectionError } from '../error-classifier.js';
|
import { analyzeConnectionError } from '../error-classifier.js';
|
||||||
import { clearOAuthCaches } from '../oauth-persistence.js';
|
import { clearOAuthCaches } from '../oauth-persistence.js';
|
||||||
import type { createRuntime } from '../runtime.js';
|
import type { createRuntime } from '../runtime.js';
|
||||||
@ -9,12 +10,23 @@ import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
|||||||
import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
|
import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||||
import { looksLikeHttpUrl } from './http-utils.js';
|
import { looksLikeHttpUrl } from './http-utils.js';
|
||||||
import { buildConnectionIssueEnvelope } from './json-output.js';
|
import { buildConnectionIssueEnvelope } from './json-output.js';
|
||||||
import { logInfo, logWarn } from './logger-context.js';
|
import { getActiveLogger, logInfo, logWarn } from './logger-context.js';
|
||||||
import { consumeOutputFormat } from './output-format.js';
|
import { consumeOutputFormat } from './output-format.js';
|
||||||
|
|
||||||
type Runtime = Awaited<ReturnType<typeof createRuntime>>;
|
type Runtime = Awaited<ReturnType<typeof createRuntime>>;
|
||||||
|
|
||||||
|
type BrowserSuppression = 'default' | 'no-browser';
|
||||||
|
|
||||||
|
const TRUE_VALUES = new Set(['1', 'true', 'yes']);
|
||||||
|
const FALSE_VALUES = new Set(['0', 'false', 'no']);
|
||||||
|
|
||||||
export async function handleAuth(runtime: Runtime, args: string[]): Promise<void> {
|
export async function handleAuth(runtime: Runtime, args: string[]): Promise<void> {
|
||||||
|
const browserSuppression = consumeBrowserSuppression(args, process.env);
|
||||||
|
const noBrowser = browserSuppression === 'no-browser';
|
||||||
|
let authorizationOutputEmitted = false;
|
||||||
|
const markAuthorizationOutputEmitted = () => {
|
||||||
|
authorizationOutputEmitted = true;
|
||||||
|
};
|
||||||
const resetIndex = args.indexOf('--reset');
|
const resetIndex = args.indexOf('--reset');
|
||||||
const shouldReset = resetIndex !== -1;
|
const shouldReset = resetIndex !== -1;
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
@ -49,13 +61,15 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
|||||||
const definition = runtime.getDefinition(target);
|
const definition = runtime.getDefinition(target);
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
await clearOAuthCaches(definition);
|
await clearOAuthCaches(definition);
|
||||||
logInfo(`Cleared cached credentials for '${target}'.`);
|
if (!noBrowser) {
|
||||||
|
logInfo(`Cleared cached credentials for '${target}'.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.command.kind === 'stdio' && definition.oauthCommand) {
|
if (definition.command.kind === 'stdio' && definition.oauthCommand) {
|
||||||
logInfo(`Starting auth helper for '${target}' (stdio). Leave this running until the browser flow completes.`);
|
logInfo(`Starting auth helper for '${target}' (stdio). Leave this running until the browser flow completes.`);
|
||||||
try {
|
try {
|
||||||
await runStdioAuth(definition);
|
await runStdioAuth(definition, { noBrowser });
|
||||||
logInfo(`Auth helper for '${target}' finished. You can now call tools.`);
|
logInfo(`Auth helper for '${target}' finished. You can now call tools.`);
|
||||||
} finally {
|
} finally {
|
||||||
await persistPreparedEphemeralServer(runtime, prepared);
|
await persistPreparedEphemeralServer(runtime, prepared);
|
||||||
@ -65,10 +79,23 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
logInfo(`Initiating OAuth flow for '${target}'...`);
|
if (!noBrowser) {
|
||||||
const tools = await runtime.listTools(target, { autoAuthorize: true });
|
logInfo(`Initiating OAuth flow for '${target}'...`);
|
||||||
|
}
|
||||||
|
const tools = await withInfoLogsSuppressed(noBrowser, () =>
|
||||||
|
runtime.listTools(target, {
|
||||||
|
autoAuthorize: true,
|
||||||
|
...(noBrowser
|
||||||
|
? {
|
||||||
|
oauthSessionOptions: buildNoBrowserOAuthOptions(format, markAuthorizationOutputEmitted),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
);
|
||||||
await persistPreparedEphemeralServer(runtime, prepared);
|
await persistPreparedEphemeralServer(runtime, prepared);
|
||||||
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
|
if (!noBrowser) {
|
||||||
|
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await persistPreparedEphemeralServer(runtime, prepared);
|
await persistPreparedEphemeralServer(runtime, prepared);
|
||||||
@ -78,12 +105,16 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
|||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (format === 'json') {
|
if (format === 'json') {
|
||||||
const payload = buildConnectionIssueEnvelope({
|
if (authorizationOutputEmitted) {
|
||||||
server: target,
|
console.error(`Failed to authorize '${target}': ${message}`);
|
||||||
error,
|
} else {
|
||||||
issue: analyzeConnectionError(error),
|
const payload = buildConnectionIssueEnvelope({
|
||||||
});
|
server: target,
|
||||||
console.log(JSON.stringify(payload, null, 2));
|
error,
|
||||||
|
issue: analyzeConnectionError(error),
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -92,16 +123,31 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runStdioAuth(definition: ServerDefinition): Promise<void> {
|
async function withInfoLogsSuppressed<T>(enabled: boolean, task: () => Promise<T>): Promise<T> {
|
||||||
|
if (!enabled) {
|
||||||
|
return task();
|
||||||
|
}
|
||||||
|
const logger = getActiveLogger();
|
||||||
|
const originalInfo = logger.info.bind(logger);
|
||||||
|
logger.info = () => {};
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
logger.info = originalInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStdioAuth(definition: ServerDefinition, options: { noBrowser?: boolean } = {}): Promise<void> {
|
||||||
const authArgs = [...(definition.command.kind === 'stdio' ? (definition.command.args ?? []) : [])];
|
const authArgs = [...(definition.command.kind === 'stdio' ? (definition.command.args ?? []) : [])];
|
||||||
if (definition.oauthCommand) {
|
if (definition.oauthCommand) {
|
||||||
authArgs.push(...definition.oauthCommand.args);
|
authArgs.push(...definition.oauthCommand.args);
|
||||||
}
|
}
|
||||||
|
const env = options.noBrowser ? { ...process.env, MCPORTER_OAUTH_NO_BROWSER: '1' } : process.env;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(definition.command.kind === 'stdio' ? definition.command.command : '', authArgs, {
|
const child = spawn(definition.command.kind === 'stdio' ? definition.command.command : '', authArgs, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
cwd: definition.command.kind === 'stdio' ? definition.command.cwd : process.cwd(),
|
cwd: definition.command.kind === 'stdio' ? definition.command.cwd : process.cwd(),
|
||||||
env: process.env,
|
env,
|
||||||
});
|
});
|
||||||
child.on('error', reject);
|
child.on('error', reject);
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
@ -114,6 +160,68 @@ async function runStdioAuth(definition: ServerDefinition): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildNoBrowserOAuthOptions(
|
||||||
|
format: 'text' | 'json',
|
||||||
|
markAuthorizationOutputEmitted: () => void
|
||||||
|
): OAuthSessionOptions {
|
||||||
|
return {
|
||||||
|
suppressBrowserLaunch: true,
|
||||||
|
onAuthorizationUrl(request: OAuthAuthorizationRequest) {
|
||||||
|
markAuthorizationOutputEmitted();
|
||||||
|
if (format === 'json') {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
authorizationUrl: request.authorizationUrl,
|
||||||
|
redirectUrl: request.redirectUrl,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(request.authorizationUrl);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeBrowserSuppression(args: string[], env: NodeJS.ProcessEnv): BrowserSuppression {
|
||||||
|
let mode = resolveBrowserSuppressionFromEnv(env.MCPORTER_OAUTH_NO_BROWSER);
|
||||||
|
const noBrowserIndex = args.indexOf('--no-browser');
|
||||||
|
if (noBrowserIndex !== -1) {
|
||||||
|
args.splice(noBrowserIndex, 1);
|
||||||
|
mode = 'no-browser';
|
||||||
|
}
|
||||||
|
const browserIndex = args.indexOf('--browser');
|
||||||
|
if (browserIndex !== -1) {
|
||||||
|
const value = args[browserIndex + 1];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Flag '--browser' requires a value.");
|
||||||
|
}
|
||||||
|
if (value !== 'none') {
|
||||||
|
throw new Error("--browser must be 'none' when provided to mcporter auth.");
|
||||||
|
}
|
||||||
|
args.splice(browserIndex, 2);
|
||||||
|
mode = 'no-browser';
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrowserSuppressionFromEnv(raw: string | undefined): BrowserSuppression {
|
||||||
|
if (raw === undefined) {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (!normalized || FALSE_VALUES.has(normalized)) {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
if (TRUE_VALUES.has(normalized)) {
|
||||||
|
return 'no-browser';
|
||||||
|
}
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRetryAuthError(error: unknown): boolean {
|
function shouldRetryAuthError(error: unknown): boolean {
|
||||||
if (isOAuthFlowError(error)) {
|
if (isOAuthFlowError(error)) {
|
||||||
return false;
|
return false;
|
||||||
@ -130,7 +238,10 @@ export function printAuthHelp(): void {
|
|||||||
'',
|
'',
|
||||||
'Common flags:',
|
'Common flags:',
|
||||||
' --reset Clear cached credentials before re-authorizing.',
|
' --reset Clear cached credentials before re-authorizing.',
|
||||||
' --json Emit a JSON envelope on failure.',
|
' --json Emit a JSON envelope on failure (and auth-start JSON with --no-browser).',
|
||||||
|
' --no-browser Print the OAuth authorization URL without launching a browser.',
|
||||||
|
' --browser none Alias for --no-browser (also supported by config login).',
|
||||||
|
' MCPORTER_OAUTH_NO_BROWSER=1|true|yes also enables --no-browser behavior.',
|
||||||
'',
|
'',
|
||||||
'Ad-hoc targets:',
|
'Ad-hoc targets:',
|
||||||
' --http-url <url> Register an HTTP server for this run.',
|
' --http-url <url> Register an HTTP server for this run.',
|
||||||
@ -147,6 +258,7 @@ export function printAuthHelp(): void {
|
|||||||
'',
|
'',
|
||||||
'Examples:',
|
'Examples:',
|
||||||
' mcporter auth linear',
|
' mcporter auth linear',
|
||||||
|
' mcporter auth linear --no-browser',
|
||||||
' mcporter auth https://mcp.example.com/mcp',
|
' mcporter auth https://mcp.example.com/mcp',
|
||||||
' mcporter auth --stdio "npx -y chrome-devtools-mcp@latest"',
|
' mcporter auth --stdio "npx -y chrome-devtools-mcp@latest"',
|
||||||
' mcporter auth --http-url http://localhost:3000/mcp --allow-http',
|
' mcporter auth --http-url http://localhost:3000/mcp --allow-http',
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export interface CallArgsParseResult {
|
|||||||
tailLog: boolean;
|
tailLog: boolean;
|
||||||
output: OutputFormat;
|
output: OutputFormat;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
disableOAuth?: boolean;
|
||||||
ephemeral?: EphemeralServerSpec;
|
ephemeral?: EphemeralServerSpec;
|
||||||
rawStrings?: boolean;
|
rawStrings?: boolean;
|
||||||
saveImagesDir?: string;
|
saveImagesDir?: string;
|
||||||
@ -59,6 +60,7 @@ const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
|||||||
'--tool': handleToolFlag,
|
'--tool': handleToolFlag,
|
||||||
'--timeout': handleTimeoutFlag,
|
'--timeout': handleTimeoutFlag,
|
||||||
'--tail-log': handleTailLogFlag,
|
'--tail-log': handleTailLogFlag,
|
||||||
|
'--no-oauth': handleDisableOAuthFlag,
|
||||||
'--save-images': handleSaveImagesFlag,
|
'--save-images': handleSaveImagesFlag,
|
||||||
'--yes': handleNoopFlag,
|
'--yes': handleNoopFlag,
|
||||||
'--raw-strings': handleRawStringsFlag,
|
'--raw-strings': handleRawStringsFlag,
|
||||||
@ -191,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
index += parsed.consumed;
|
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 (parsed.key === 'tool' && !result.tool) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new Error("Argument 'tool' must be a string value.");
|
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') {
|
if (state.coercionMode === 'default' && typeof value === 'number') {
|
||||||
result.schemaStringCoercionCandidates ??= {};
|
result.schemaStringCoercionCandidates ??= {};
|
||||||
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
|
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
|
||||||
}
|
}
|
||||||
result.args[parsed.key] = value;
|
result.args[parsed.key] = value;
|
||||||
}
|
}
|
||||||
@ -256,6 +258,11 @@ function handleTailLogFlag(context: FlagHandlerContext): number {
|
|||||||
return context.index + 1;
|
return context.index + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDisableOAuthFlag(context: FlagHandlerContext): number {
|
||||||
|
context.result.disableOAuth = true;
|
||||||
|
return context.index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSaveImagesFlag(context: FlagHandlerContext): number {
|
function handleSaveImagesFlag(context: FlagHandlerContext): number {
|
||||||
context.result.saveImagesDir = consumeFlagValue(
|
context.result.saveImagesDir = consumeFlagValue(
|
||||||
context.args,
|
context.args,
|
||||||
@ -320,18 +327,53 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
|
|||||||
eqIndex === -1
|
eqIndex === -1
|
||||||
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
||||||
: body.slice(eqIndex + 1);
|
: 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') {
|
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
||||||
context.result.schemaStringCoercionCandidates ??= {};
|
context.result.schemaStringCoercionCandidates ??= {};
|
||||||
context.result.schemaStringCoercionCandidates[key] = rawValue;
|
context.result.schemaStringCoercionCandidates[key] = schemaValue;
|
||||||
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
||||||
context.result.schemaArrayCoercionCandidates ??= {};
|
context.result.schemaArrayCoercionCandidates ??= {};
|
||||||
context.result.schemaArrayCoercionCandidates[key] = rawValue;
|
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
|
||||||
}
|
}
|
||||||
context.result.args[key] = value;
|
context.result.args[key] = value;
|
||||||
return context.index + (eqIndex === -1 ? 2 : 1);
|
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 {
|
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
||||||
if (!rawKey || rawKey.startsWith('-')) {
|
if (!rawKey || rawKey.startsWith('-')) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@ -39,6 +39,7 @@ interface PreparedCallRequest extends ResolvedCallTarget {
|
|||||||
parsed: CallArgsParseResult;
|
parsed: CallArgsParseResult;
|
||||||
hydratedArgs: Record<string, unknown>;
|
hydratedArgs: Record<string, unknown>;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
disableOAuth?: boolean;
|
||||||
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,12 +67,19 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
|||||||
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
|
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
|
||||||
const { server, tool } = await resolveServerAndTool(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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
|
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(
|
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
|
||||||
runtime,
|
runtime,
|
||||||
server,
|
server,
|
||||||
@ -79,9 +87,18 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
|||||||
hydratedArgs,
|
hydratedArgs,
|
||||||
parsed.schemaStringCoercionCandidates,
|
parsed.schemaStringCoercionCandidates,
|
||||||
parsed.schemaArrayCoercionCandidates,
|
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(
|
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.');
|
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
|
||||||
}
|
}
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
tool = await inferSingleToolName(runtime, server);
|
tool = await inferSingleToolName(runtime, server, parsed.disableOAuth);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
||||||
}
|
}
|
||||||
@ -165,7 +182,8 @@ async function invokePreparedCall(
|
|||||||
prepared.tool,
|
prepared.tool,
|
||||||
prepared.hydratedArgs,
|
prepared.hydratedArgs,
|
||||||
prepared.timeoutMs,
|
prepared.timeoutMs,
|
||||||
prepared.parsed.output
|
prepared.parsed.output,
|
||||||
|
prepared.disableOAuth
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
||||||
@ -224,11 +242,15 @@ async function maybeDescribeServer(
|
|||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: string,
|
server: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
outputFormat: OutputFormat
|
outputFormat: OutputFormat,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (tool === 'list_tools') {
|
if (tool === 'list_tools') {
|
||||||
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
|
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
|
||||||
const listArgs = [server];
|
const listArgs = [server];
|
||||||
|
if (disableOAuth) {
|
||||||
|
listArgs.push('--no-oauth');
|
||||||
|
}
|
||||||
if (outputFormat === 'json') {
|
if (outputFormat === 'json') {
|
||||||
listArgs.push('--json');
|
listArgs.push('--json');
|
||||||
}
|
}
|
||||||
@ -239,7 +261,9 @@ async function maybeDescribeServer(
|
|||||||
if (tool !== 'help') {
|
if (tool !== 'help') {
|
||||||
return false;
|
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) {
|
if (!tools) {
|
||||||
return false;
|
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.`));
|
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
|
||||||
const listArgs = [server];
|
const listArgs = [server];
|
||||||
|
if (disableOAuth) {
|
||||||
|
listArgs.push('--no-oauth');
|
||||||
|
}
|
||||||
if (outputFormat === 'json') {
|
if (outputFormat === 'json') {
|
||||||
listArgs.push('--json');
|
listArgs.push('--json');
|
||||||
}
|
}
|
||||||
@ -296,7 +323,8 @@ async function enforceSchemaAwareArgumentTypes(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
stringCandidates: Record<string, string> | undefined,
|
stringCandidates: Record<string, string> | undefined,
|
||||||
arrayCandidates: Record<string, string> | undefined,
|
arrayCandidates: Record<string, string> | undefined,
|
||||||
timeoutMs: number
|
timeoutMs: number,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
if (
|
if (
|
||||||
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
||||||
@ -305,9 +333,10 @@ async function enforceSchemaAwareArgumentTypes(
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = await withTimeout(loadToolMetadata(runtime, server, { includeSchema: true }), timeoutMs).catch(
|
const tools = await withTimeout(
|
||||||
() => undefined
|
loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }),
|
||||||
);
|
timeoutMs
|
||||||
|
).catch(() => undefined);
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
@ -389,14 +418,15 @@ async function hydratePositionalArguments(
|
|||||||
server: string,
|
server: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
namedArgs: Record<string, unknown>,
|
namedArgs: Record<string, unknown>,
|
||||||
positionalArgs: unknown[] | undefined
|
positionalArgs: unknown[] | undefined,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
if (!positionalArgs || positionalArgs.length === 0) {
|
if (!positionalArgs || positionalArgs.length === 0) {
|
||||||
return namedArgs;
|
return namedArgs;
|
||||||
}
|
}
|
||||||
// We need the schema order to know which field each positional argument maps to; pull the
|
// 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.
|
// 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) {
|
if (!tools) {
|
||||||
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
||||||
}
|
}
|
||||||
@ -436,9 +466,10 @@ type ToolResolution = IdentifierResolution;
|
|||||||
|
|
||||||
async function inferSingleToolName(
|
async function inferSingleToolName(
|
||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: string
|
server: string,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<string | 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) {
|
if (tools.length !== 1) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -456,10 +487,11 @@ async function invokeWithAutoCorrection(
|
|||||||
tool: string,
|
tool: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
outputFormat: OutputFormat
|
outputFormat: OutputFormat,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
): 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.
|
// 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(
|
async function attemptCall(
|
||||||
@ -469,14 +501,24 @@ async function attemptCall(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
outputFormat: OutputFormat,
|
outputFormat: OutputFormat,
|
||||||
allowCorrection: boolean
|
allowCorrection: boolean,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||||
try {
|
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)) {
|
if (allowCorrection && isErrorCallResult(result)) {
|
||||||
const resolution = await maybeResolveToolName(runtime, server, tool, result);
|
const resolution = await maybeResolveToolName(runtime, server, tool, result, disableOAuth);
|
||||||
if (resolution) {
|
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) {
|
if (retry) {
|
||||||
return retry;
|
return retry;
|
||||||
}
|
}
|
||||||
@ -497,13 +539,22 @@ async function attemptCall(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolution = await maybeResolveToolName(runtime, server, tool, error);
|
const resolution = await maybeResolveToolName(runtime, server, tool, error, disableOAuth);
|
||||||
if (!resolution) {
|
if (!resolution) {
|
||||||
maybeReportConnectionIssue(server, tool, error);
|
maybeReportConnectionIssue(server, tool, error);
|
||||||
throw 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) {
|
if (!retry) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -518,7 +569,8 @@ async function maybeRetryResolvedTool(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
outputFormat: OutputFormat,
|
outputFormat: OutputFormat,
|
||||||
resolution: ToolResolution
|
resolution: ToolResolution,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
||||||
const messages = renderIdentifierResolutionMessages({
|
const messages = renderIdentifierResolutionMessages({
|
||||||
entity: 'tool',
|
entity: 'tool',
|
||||||
@ -536,14 +588,15 @@ async function maybeRetryResolvedTool(
|
|||||||
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
|
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
|
||||||
emitAutoMessage(dimText(messages.auto));
|
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(
|
async function maybeResolveToolName(
|
||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: string,
|
server: string,
|
||||||
attemptedTool: string,
|
attemptedTool: string,
|
||||||
error: unknown
|
error: unknown,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<ToolResolution | undefined> {
|
): Promise<ToolResolution | undefined> {
|
||||||
const missingName = extractMissingToolFromError(error);
|
const missingName = extractMissingToolFromError(error);
|
||||||
if (!missingName) {
|
if (!missingName) {
|
||||||
@ -555,7 +608,7 @@ async function maybeResolveToolName(
|
|||||||
return undefined;
|
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) {
|
if (!tools) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export const CALL_HELP_ARGUMENT_LINES = [
|
export const CALL_HELP_ARGUMENT_LINES = [
|
||||||
' key=value / key:value Flag-style named arguments.',
|
' 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)\'.',
|
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
|
||||||
' --args <json> Provide a JSON object payload.',
|
' --args <json> Provide a JSON object payload.',
|
||||||
' positional values Accepted when schema order is known.',
|
' 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.',
|
' --timeout <ms> Override the call timeout.',
|
||||||
' --output text|markdown|json|raw Control formatting.',
|
' --output text|markdown|json|raw Control formatting.',
|
||||||
' --save-images <dir> Save image content blocks to a directory.',
|
' --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.',
|
' --raw-strings Keep numeric-looking argument values as strings.',
|
||||||
' --no-coerce Keep all key/value and positional arguments as raw strings.',
|
' --no-coerce Keep all key/value and positional arguments as raw strings.',
|
||||||
' --tail-log Stream returned log handles.',
|
' --tail-log Stream returned log handles.',
|
||||||
@ -31,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
|
|||||||
|
|
||||||
export const CALL_HELP_EXAMPLE_LINES = [
|
export const CALL_HELP_EXAMPLE_LINES = [
|
||||||
' mcporter call linear.list_issues team=ENG limit:5',
|
' 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 "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
|
||||||
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
||||||
' mcporter call --stdio "bun run ./server.ts" scrape 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 { parseLogLevel } from '../logging.js';
|
||||||
import { extractFlags } from './flag-utils.js';
|
import { extractFlags } from './flag-utils.js';
|
||||||
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
|
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
|
||||||
|
import { parsePositiveInteger } from './timeouts.js';
|
||||||
|
|
||||||
export interface GlobalCliContext {
|
export interface GlobalCliContext {
|
||||||
readonly globalFlags: Record<string, string | undefined>;
|
readonly globalFlags: Record<string, string | undefined>;
|
||||||
@ -29,8 +30,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
|
|||||||
|
|
||||||
let oauthTimeoutOverride: number | undefined;
|
let oauthTimeoutOverride: number | undefined;
|
||||||
if (globalFlags['--oauth-timeout']) {
|
if (globalFlags['--oauth-timeout']) {
|
||||||
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
|
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (parsed === undefined) {
|
||||||
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
|
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
|
||||||
return { exit: true, code: 1 };
|
return { exit: true, code: 1 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,7 +84,14 @@ function isCallLikeToken(token: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isExplicitCommand(token: string): boolean {
|
function isExplicitCommand(token: string): boolean {
|
||||||
return token === 'list' || token === 'call' || token === 'auth' || token === 'resource' || token === 'resources';
|
return (
|
||||||
|
token === 'list' ||
|
||||||
|
token === 'call' ||
|
||||||
|
token === 'auth' ||
|
||||||
|
token === 'vault' ||
|
||||||
|
token === 'resource' ||
|
||||||
|
token === 'resources'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUrlToken(token: string): boolean {
|
function isUrlToken(token: string): boolean {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { LoadConfigOptions, RawEntry } from '../../config.js';
|
import { writeRawConfig, type LoadConfigOptions, type RawEntry } from '../../config.js';
|
||||||
import { writeRawConfig } from '../../config.js';
|
|
||||||
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
||||||
import { expandHome } from '../../env.js';
|
import { expandHome } from '../../env.js';
|
||||||
|
import { withFileLock } from '../../fs-json.js';
|
||||||
import { mcporterDir } from '../../paths.js';
|
import { mcporterDir } from '../../paths.js';
|
||||||
import { CliUsageError } from '../errors.js';
|
import { CliUsageError } from '../errors.js';
|
||||||
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
||||||
@ -44,9 +44,6 @@ export async function handleAddCommand(options: ConfigCliOptions, args: string[]
|
|||||||
const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd());
|
const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd());
|
||||||
const effectiveLoadOptions: LoadConfigOptions = { ...options.loadOptions, configPath: targetPath };
|
const effectiveLoadOptions: LoadConfigOptions = { ...options.loadOptions, configPath: targetPath };
|
||||||
|
|
||||||
const { config, path: configPath } = await loadOrCreateConfig(effectiveLoadOptions);
|
|
||||||
const nextConfig = cloneConfig(config);
|
|
||||||
|
|
||||||
const baseEntry = await resolveBaseEntry(flags.copyFrom, options.loadOptions);
|
const baseEntry = await resolveBaseEntry(flags.copyFrom, options.loadOptions);
|
||||||
const entry: RawEntry = baseEntry ? { ...baseEntry } : {};
|
const entry: RawEntry = baseEntry ? { ...baseEntry } : {};
|
||||||
|
|
||||||
@ -72,18 +69,23 @@ export async function handleAddCommand(options: ConfigCliOptions, args: string[]
|
|||||||
throw new CliUsageError('Server definitions require either a --url/target or a stdio command.');
|
throw new CliUsageError('Server definitions require either a --url/target or a stdio command.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextConfig.mcpServers) {
|
|
||||||
nextConfig.mcpServers = {};
|
|
||||||
}
|
|
||||||
nextConfig.mcpServers[name] = entry;
|
|
||||||
|
|
||||||
if (flags.dryRun) {
|
if (flags.dryRun) {
|
||||||
console.log(JSON.stringify({ [name]: entry }, null, 2));
|
console.log(JSON.stringify({ [name]: entry }, null, 2));
|
||||||
console.log('(dry-run) No changes were written.');
|
console.log('(dry-run) No changes were written.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRawConfig(configPath, nextConfig);
|
let configPath = targetPath;
|
||||||
|
await withFileLock(targetPath, async () => {
|
||||||
|
const loaded = await loadOrCreateConfig(effectiveLoadOptions);
|
||||||
|
configPath = loaded.path;
|
||||||
|
const nextConfig = cloneConfig(loaded.config);
|
||||||
|
if (!nextConfig.mcpServers) {
|
||||||
|
nextConfig.mcpServers = {};
|
||||||
|
}
|
||||||
|
nextConfig.mcpServers[name] = entry;
|
||||||
|
await writeRawConfig(configPath, nextConfig);
|
||||||
|
});
|
||||||
console.log(`Added '${name}' to ${configPath}`);
|
console.log(`Added '${name}' to ${configPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,8 +98,21 @@ export const CONFIG_HELP_ENTRIES: Record<ConfigSubcommand, ConfigHelpEntry> = {
|
|||||||
name: 'login <name|url> [options]',
|
name: 'login <name|url> [options]',
|
||||||
summary: 'Run the OAuth/auth flow',
|
summary: 'Run the OAuth/auth flow',
|
||||||
usage: 'mcporter config login <name|url> [options]',
|
usage: 'mcporter config login <name|url> [options]',
|
||||||
description: 'Delegates to `mcporter auth`, so you can pass ephemeral flags like --http-url/--stdio/--reset.',
|
description:
|
||||||
examples: ['pnpm mcporter config login linear', 'pnpm mcporter config login https://example.com/mcp --reset'],
|
'Delegates to `mcporter auth`, so you can pass ephemeral flags like --http-url/--stdio/--reset and browser-suppression flags for headless OAuth.',
|
||||||
|
flags: [
|
||||||
|
{ flag: '--no-browser', description: 'Print the OAuth authorization URL without launching a browser.' },
|
||||||
|
{ flag: '--browser none', description: 'Alias for --no-browser.' },
|
||||||
|
{
|
||||||
|
flag: 'MCPORTER_OAUTH_NO_BROWSER=1|true|yes',
|
||||||
|
description: 'Environment default for browser-suppressed OAuth.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examples: [
|
||||||
|
'pnpm mcporter config login linear',
|
||||||
|
'pnpm mcporter config login linear --no-browser',
|
||||||
|
'pnpm mcporter config login https://example.com/mcp --reset',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
logout: {
|
logout: {
|
||||||
name: 'logout <name>',
|
name: 'logout <name>',
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { RawEntry } from '../../config.js';
|
import { resolveConfigPath, writeRawConfig, type RawEntry } from '../../config.js';
|
||||||
import { writeRawConfig } from '../../config.js';
|
|
||||||
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
||||||
import { expandHome } from '../../env.js';
|
import { expandHome } from '../../env.js';
|
||||||
|
import { withFileLock } from '../../fs-json.js';
|
||||||
import { CliUsageError } from '../errors.js';
|
import { CliUsageError } from '../errors.js';
|
||||||
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
||||||
import type { ConfigCliOptions } from './types.js';
|
import type { ConfigCliOptions } from './types.js';
|
||||||
@ -53,19 +53,31 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (flags.copy) {
|
if (flags.copy) {
|
||||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
const lockPath = resolveImportCopyTarget(options.loadOptions.configPath, rootDir);
|
||||||
const nextConfig = cloneConfig(config);
|
let configPath = lockPath;
|
||||||
if (!nextConfig.mcpServers) {
|
await withFileLock(lockPath, async () => {
|
||||||
nextConfig.mcpServers = {};
|
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
|
||||||
}
|
configPath = loaded.path;
|
||||||
for (const item of entries) {
|
const nextConfig = cloneConfig(loaded.config);
|
||||||
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
|
if (!nextConfig.mcpServers) {
|
||||||
}
|
nextConfig.mcpServers = {};
|
||||||
await writeRawConfig(configPath, nextConfig);
|
}
|
||||||
|
for (const item of entries) {
|
||||||
|
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
|
||||||
|
}
|
||||||
|
await writeRawConfig(configPath, nextConfig);
|
||||||
|
});
|
||||||
console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`);
|
console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function extractImportFlags(args: string[]): ImportFlags {
|
||||||
const flags: ImportFlags = { format: 'text' };
|
const flags: ImportFlags = { format: 'text' };
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { writeRawConfig } from '../../config.js';
|
import { resolveConfigPath, writeRawConfig } from '../../config.js';
|
||||||
|
import { withFileLock } from '../../fs-json.js';
|
||||||
import { CliUsageError } from '../errors.js';
|
import { CliUsageError } from '../errors.js';
|
||||||
import { cloneConfig, findServerNameWithFuzzyMatch, loadOrCreateConfig } from './shared.js';
|
import { cloneConfig, findServerNameWithFuzzyMatch, loadOrCreateConfig } from './shared.js';
|
||||||
import type { ConfigCliOptions } from './types.js';
|
import type { ConfigCliOptions } from './types.js';
|
||||||
@ -8,13 +9,21 @@ export async function handleRemoveCommand(options: ConfigCliOptions, args: strin
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
throw new CliUsageError('Usage: mcporter config remove <name>');
|
throw new CliUsageError('Usage: mcporter config remove <name>');
|
||||||
}
|
}
|
||||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
const rootDir = options.loadOptions.rootDir ?? process.cwd();
|
||||||
const targetName = findServerNameWithFuzzyMatch(name, Object.keys(config.mcpServers ?? {}));
|
const lockPath = resolveConfigPath(options.loadOptions.configPath, rootDir).path;
|
||||||
if (!targetName) {
|
let configPath = lockPath;
|
||||||
throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`);
|
let targetName = name;
|
||||||
}
|
await withFileLock(lockPath, async () => {
|
||||||
const nextConfig = cloneConfig(config);
|
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
|
||||||
delete nextConfig.mcpServers[targetName];
|
configPath = loaded.path;
|
||||||
await writeRawConfig(configPath, nextConfig);
|
const matched = findServerNameWithFuzzyMatch(name, Object.keys(loaded.config.mcpServers ?? {}));
|
||||||
|
if (!matched) {
|
||||||
|
throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`);
|
||||||
|
}
|
||||||
|
targetName = matched;
|
||||||
|
const nextConfig = cloneConfig(loaded.config);
|
||||||
|
delete nextConfig.mcpServers[targetName];
|
||||||
|
await writeRawConfig(configPath, nextConfig);
|
||||||
|
});
|
||||||
console.log(`Removed '${targetName}' from ${configPath}`);
|
console.log(`Removed '${targetName}' from ${configPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export type SerializedServerDefinition = {
|
|||||||
oauthTokenEndpointAuthMethod?: string;
|
oauthTokenEndpointAuthMethod?: string;
|
||||||
oauthRedirectUrl?: string;
|
oauthRedirectUrl?: string;
|
||||||
oauthScope?: string;
|
oauthScope?: string;
|
||||||
|
refresh?: ServerDefinition['refresh'];
|
||||||
|
httpFetch?: ServerDefinition['httpFetch'];
|
||||||
allowedTools?: readonly string[];
|
allowedTools?: readonly string[];
|
||||||
blockedTools?: readonly string[];
|
blockedTools?: readonly string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
@ -40,6 +42,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
|||||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||||
oauthScope: definition.oauthScope,
|
oauthScope: definition.oauthScope,
|
||||||
|
refresh: definition.refresh,
|
||||||
|
httpFetch: definition.httpFetch,
|
||||||
allowedTools: definition.allowedTools,
|
allowedTools: definition.allowedTools,
|
||||||
blockedTools: definition.blockedTools,
|
blockedTools: definition.blockedTools,
|
||||||
env: definition.env,
|
env: definition.env,
|
||||||
@ -60,6 +64,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
|||||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||||
oauthScope: definition.oauthScope,
|
oauthScope: definition.oauthScope,
|
||||||
|
refresh: definition.refresh,
|
||||||
|
httpFetch: definition.httpFetch,
|
||||||
allowedTools: definition.allowedTools,
|
allowedTools: definition.allowedTools,
|
||||||
blockedTools: definition.blockedTools,
|
blockedTools: definition.blockedTools,
|
||||||
env: definition.env,
|
env: definition.env,
|
||||||
@ -91,8 +97,8 @@ export function printServerSummary(definition: ServerDefinition): void {
|
|||||||
if (definition.description) {
|
if (definition.description) {
|
||||||
console.log(` ${label('Description')}: ${definition.description}`);
|
console.log(` ${label('Description')}: ${definition.description}`);
|
||||||
}
|
}
|
||||||
if (definition.auth === 'oauth') {
|
if (definition.auth) {
|
||||||
console.log(` ${label('Auth')}: oauth`);
|
console.log(` ${label('Auth')}: ${definition.auth}`);
|
||||||
}
|
}
|
||||||
if (definition.allowedTools !== undefined) {
|
if (definition.allowedTools !== undefined) {
|
||||||
const rendered = definition.allowedTools.length > 0 ? definition.allowedTools.join(', ') : '<none>';
|
const rendered = definition.allowedTools.length > 0 ? definition.allowedTools.join(', ') : '<none>';
|
||||||
|
|||||||
@ -259,7 +259,7 @@ async function writeFile(targetPath: string, contents: string): Promise<void> {
|
|||||||
function computeImportPath(fromPath: string, typesPath: string): string {
|
function computeImportPath(fromPath: string, typesPath: string): string {
|
||||||
const fromDir = path.dirname(fromPath);
|
const fromDir = path.dirname(fromPath);
|
||||||
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
|
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('.')) {
|
if (withoutExt.startsWith('.')) {
|
||||||
return withoutExt;
|
return withoutExt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,9 @@ export function extractFlags(args: string[], keys: readonly string[]): FlagMap {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
while (index < args.length) {
|
while (index < args.length) {
|
||||||
const token = args[index];
|
const token = args[index];
|
||||||
|
if (token === '--') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (token === undefined || !keys.includes(token)) {
|
if (token === undefined || !keys.includes(token)) {
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -99,6 +99,8 @@ export function printGenerateCliHelp(): void {
|
|||||||
' --compile [path] Emit a Bun-compiled binary.',
|
' --compile [path] Emit a Bun-compiled binary.',
|
||||||
' --runtime node|bun Runtime for generated code.',
|
' --runtime node|bun Runtime for generated code.',
|
||||||
' --bundler rolldown|bun Bundler for JavaScript output.',
|
' --bundler rolldown|bun Bundler for JavaScript output.',
|
||||||
|
' --timeout <ms> Discovery/call timeout in milliseconds.',
|
||||||
|
' --minify / --no-minify Toggle bundle minification.',
|
||||||
' --include-tools a,b Generate only these tools.',
|
' --include-tools a,b Generate only these tools.',
|
||||||
' --exclude-tools a,b Omit these tools.',
|
' --exclude-tools a,b Omit these tools.',
|
||||||
' --dry-run Print regeneration command for --from.',
|
' --dry-run Print regeneration command for --from.',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import fsSync from 'node:fs';
|
import fsSync from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { createRequire } from 'node:module';
|
import { builtinModules, createRequire } from 'node:module';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import type { RolldownPlugin } from 'rolldown';
|
import type { RolldownPlugin } from 'rolldown';
|
||||||
@ -16,6 +16,7 @@ const packageRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
|||||||
// even in empty temp dirs (fixes #1).
|
// even in empty temp dirs (fixes #1).
|
||||||
const BUNDLED_DEPENDENCIES = ['commander', 'mcporter', 'jsonc-parser'] as const;
|
const BUNDLED_DEPENDENCIES = ['commander', 'mcporter', 'jsonc-parser'] as const;
|
||||||
const dependencyAliasPlugin = createLocalDependencyAliasPlugin([...BUNDLED_DEPENDENCIES]);
|
const dependencyAliasPlugin = createLocalDependencyAliasPlugin([...BUNDLED_DEPENDENCIES]);
|
||||||
|
const NODE_BUILTIN_SPECIFIERS = new Set(builtinModules.flatMap((specifier) => [specifier, `node:${specifier}`]));
|
||||||
|
|
||||||
export async function bundleOutput({
|
export async function bundleOutput({
|
||||||
sourcePath,
|
sourcePath,
|
||||||
@ -70,20 +71,52 @@ async function bundleWithRolldown({
|
|||||||
if (typeof (log as { code?: string }).code === 'string' && (log as { code?: string }).code === 'EVAL') {
|
if (typeof (log as { code?: string }).code === 'string' && (log as { code?: string }).code === 'EVAL') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExpectedNodeBuiltinWarning(log)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
handler(level, log);
|
handler(level, log);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const format = outputFormatForTarget(absTarget, runtimeKind);
|
||||||
await bundle.write({
|
await bundle.write({
|
||||||
file: absTarget,
|
file: absTarget,
|
||||||
format: runtimeKind === 'bun' ? 'esm' : 'cjs',
|
format,
|
||||||
codeSplitting: false,
|
codeSplitting: false,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
minify,
|
minify,
|
||||||
|
...(format === 'esm' ? { banner: buildEsmRequireBanner() } : {}),
|
||||||
});
|
});
|
||||||
await markExecutable(absTarget);
|
await markExecutable(absTarget);
|
||||||
return absTarget;
|
return absTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExpectedNodeBuiltinWarning(log: unknown): boolean {
|
||||||
|
const record = log as { code?: string; message?: string };
|
||||||
|
if (record.code !== 'UNRESOLVED_IMPORT' || typeof record.message !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const match = record.message.match(/Could not resolve ['"]([^'"]+)['"]/);
|
||||||
|
return Boolean(match?.[1] && NODE_BUILTIN_SPECIFIERS.has(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEsmRequireBanner(): string {
|
||||||
|
return [
|
||||||
|
'import { createRequire as __mcporterCreateRequire } from "node:module";',
|
||||||
|
'const require = __mcporterCreateRequire(import.meta.url);',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputFormatForTarget(targetPath: string, runtimeKind: 'node' | 'bun'): 'cjs' | 'esm' {
|
||||||
|
const extension = path.extname(targetPath).toLowerCase();
|
||||||
|
if (extension === '.mjs') {
|
||||||
|
return 'esm';
|
||||||
|
}
|
||||||
|
if (extension === '.cjs') {
|
||||||
|
return 'cjs';
|
||||||
|
}
|
||||||
|
return runtimeKind === 'bun' ? 'esm' : 'cjs';
|
||||||
|
}
|
||||||
|
|
||||||
async function bundleWithBun({
|
async function bundleWithBun({
|
||||||
sourcePath,
|
sourcePath,
|
||||||
targetPath,
|
targetPath,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
type HttpCommand,
|
type HttpCommand,
|
||||||
loadServerDefinitions,
|
loadServerDefinitions,
|
||||||
type RawLifecycle,
|
type RawLifecycle,
|
||||||
|
type RefreshableBearerOptions,
|
||||||
type ServerDefinition,
|
type ServerDefinition,
|
||||||
type ServerLoggingOptions,
|
type ServerLoggingOptions,
|
||||||
type StdioCommand,
|
type StdioCommand,
|
||||||
@ -205,6 +206,8 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
|
|||||||
);
|
);
|
||||||
const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : undefined;
|
const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : undefined;
|
||||||
const oauthScope = typeof def.oauthScope === 'string' ? def.oauthScope : undefined;
|
const oauthScope = typeof def.oauthScope === 'string' ? def.oauthScope : undefined;
|
||||||
|
const refresh = getRefresh(record.refresh);
|
||||||
|
const httpFetch = normalizeHttpFetch(stringFromAliases(record, 'httpFetch', 'http_fetch'));
|
||||||
const headers = toStringRecord((def as Record<string, unknown>).headers);
|
const headers = toStringRecord((def as Record<string, unknown>).headers);
|
||||||
const oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command);
|
const oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command);
|
||||||
const rawLifecycle = getRawLifecycle(record.lifecycle);
|
const rawLifecycle = getRawLifecycle(record.lifecycle);
|
||||||
@ -228,6 +231,8 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
|
|||||||
oauthRedirectUrl,
|
oauthRedirectUrl,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
oauthCommand,
|
oauthCommand,
|
||||||
|
refresh,
|
||||||
|
httpFetch,
|
||||||
lifecycle: resolveLifecycle(name, rawLifecycle, command),
|
lifecycle: resolveLifecycle(name, rawLifecycle, command),
|
||||||
logging,
|
logging,
|
||||||
...(allowedTools !== undefined ? { allowedTools } : {}),
|
...(allowedTools !== undefined ? { allowedTools } : {}),
|
||||||
@ -382,6 +387,32 @@ function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | und
|
|||||||
return args ? { args } : undefined;
|
return args ? { args } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRefresh(value: unknown): RefreshableBearerOptions | undefined {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const tokenEndpoint = stringFromAliases(record, 'tokenEndpoint', 'token_endpoint');
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const refreshSkewSeconds = record.refreshSkewSeconds ?? record.refresh_skew_seconds;
|
||||||
|
return {
|
||||||
|
tokenEndpoint,
|
||||||
|
clientIdEnv: stringFromAliases(record, 'clientIdEnv', 'client_id_env'),
|
||||||
|
clientSecretEnv: stringFromAliases(record, 'clientSecretEnv', 'client_secret_env'),
|
||||||
|
clientAuthMethod: stringFromAliases(record, 'clientAuthMethod', 'client_auth_method'),
|
||||||
|
...(typeof refreshSkewSeconds === 'number' && Number.isInteger(refreshSkewSeconds) && refreshSkewSeconds >= 0
|
||||||
|
? { refreshSkewSeconds }
|
||||||
|
: {}),
|
||||||
|
accessTokenEnv: stringFromAliases(record, 'accessTokenEnv', 'access_token_env'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHttpFetch(value: string | undefined): ServerDefinition['httpFetch'] | undefined {
|
||||||
|
return value === 'default' || value === 'node-http1' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function stringFromAliases(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
function stringFromAliases(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { parsePositiveInteger } from '../timeouts.js';
|
||||||
|
|
||||||
export interface GeneratorCommonFlags {
|
export interface GeneratorCommonFlags {
|
||||||
runtime?: 'node' | 'bun';
|
runtime?: 'node' | 'bun';
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@ -31,8 +33,8 @@ export function extractGeneratorFlags(args: string[], options: ExtractOptions =
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
throw new Error("Flag '--timeout' requires a value.");
|
throw new Error("Flag '--timeout' requires a value.");
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(raw, 10);
|
const parsed = parsePositiveInteger(raw);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (parsed === undefined) {
|
||||||
throw new Error('--timeout must be a positive integer.');
|
throw new Error('--timeout must be a positive integer.');
|
||||||
}
|
}
|
||||||
result.timeout = parsed;
|
result.timeout = parsed;
|
||||||
|
|||||||
@ -17,7 +17,9 @@ export async function performGenerateFromArtifact(
|
|||||||
|
|
||||||
export async function performGenerateFromRequest(request: GenerateCliOptions): Promise<void> {
|
export async function performGenerateFromRequest(request: GenerateCliOptions): Promise<void> {
|
||||||
const { outputPath, bundlePath, compilePath } = await generateCli(request);
|
const { outputPath, bundlePath, compilePath } = await generateCli(request);
|
||||||
console.log(`Generated CLI at ${outputPath}`);
|
if (request.outputPath || (!bundlePath && !compilePath)) {
|
||||||
|
console.log(`Generated CLI at ${outputPath}`);
|
||||||
|
}
|
||||||
if (bundlePath) {
|
if (bundlePath) {
|
||||||
console.log(`Bundled executable created at ${bundlePath}`);
|
console.log(`Bundled executable created at ${bundlePath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ function renderStandaloneHelp(): string {
|
|||||||
\tif (generatorInfo) {
|
\tif (generatorInfo) {
|
||||||
\t\tlines.push('', tint.extraDim(generatorInfo));
|
\t\tlines.push('', tint.extraDim(generatorInfo));
|
||||||
\t}
|
\t}
|
||||||
\treturn lines.join('\\n');
|
\treturn lines.join('\\n') + '\\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
program.helpInformation = () => renderStandaloneHelp();
|
program.helpInformation = () => renderStandaloneHelp();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { markExecutable } from './fs-helpers.js';
|
|||||||
import { renderEmbeddedHelpSource } from './template-help.js';
|
import { renderEmbeddedHelpSource } from './template-help.js';
|
||||||
import type { GeneratedOption, ToolMetadata } from './tools.js';
|
import type { GeneratedOption, ToolMetadata } from './tools.js';
|
||||||
import { buildEmbeddedSchemaMap } from './tools.js';
|
import { buildEmbeddedSchemaMap } from './tools.js';
|
||||||
|
import { stableJsonStringify } from './stable-json.js';
|
||||||
|
|
||||||
export interface TemplateInput {
|
export interface TemplateInput {
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
@ -75,9 +76,12 @@ export function renderTemplate({
|
|||||||
"import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';",
|
"import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';",
|
||||||
"import { createCallResult } from 'mcporter';",
|
"import { createCallResult } from 'mcporter';",
|
||||||
].join('\n');
|
].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 relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
|
||||||
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/steipete/mcporter`;
|
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/openclaw/mcporter`;
|
||||||
const toolDocs = tools.map((tool) => ({
|
const toolDocs = tools.map((tool) => ({
|
||||||
tool,
|
tool,
|
||||||
doc: buildToolDoc({
|
doc: buildToolDoc({
|
||||||
@ -97,6 +101,7 @@ export function renderTemplate({
|
|||||||
tool: entry.tool,
|
tool: entry.tool,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assertUniqueGeneratedCommandNames(renderedTools);
|
||||||
const toolHelp = renderedTools.map((entry) => ({
|
const toolHelp = renderedTools.map((entry) => ({
|
||||||
name: entry.commandName,
|
name: entry.commandName,
|
||||||
description: entry.tool.tool.description ?? '',
|
description: entry.tool.tool.description ?? '',
|
||||||
@ -104,15 +109,13 @@ export function renderTemplate({
|
|||||||
flags: entry.doc.flagUsage ?? '',
|
flags: entry.doc.flagUsage ?? '',
|
||||||
}));
|
}));
|
||||||
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
|
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
|
||||||
const toolHelpLiteral = JSON.stringify(toolHelp, undefined, 2);
|
const toolHelpLiteral = stableJsonStringify(toolHelp, 2);
|
||||||
const embeddedSchemas = JSON.stringify(buildEmbeddedSchemaMap(tools), undefined, 2);
|
const embeddedSchemas = stableJsonStringify(buildEmbeddedSchemaMap(tools), 2);
|
||||||
const embeddedMetadata = JSON.stringify(metadata, undefined, 2);
|
const embeddedMetadata = stableJsonStringify(metadata, 2);
|
||||||
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
|
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
|
||||||
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
|
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
|
||||||
const signatureMapLiteral = JSON.stringify(signatureMap, undefined, 2);
|
const signatureMapLiteral = stableJsonStringify(signatureMap, 2);
|
||||||
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version} on ${
|
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version}. DO NOT EDIT.`;
|
||||||
metadata.generatedAt
|
|
||||||
}. DO NOT EDIT.`;
|
|
||||||
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
|
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
|
||||||
${generatedHeaderComment}
|
${generatedHeaderComment}
|
||||||
${imports}
|
${imports}
|
||||||
@ -235,7 +238,7 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
|
|||||||
\t}
|
\t}
|
||||||
\tconst values = value.split(',').map((entry) => entry.trim());
|
\tconst values = value.split(',').map((entry) => entry.trim());
|
||||||
\tif (itemType === 'number') {
|
\tif (itemType === 'number') {
|
||||||
\t\treturn values.map((entry) => parseFloat(entry));
|
\t\treturn values.map((entry) => parseFiniteNumber(entry));
|
||||||
\t}
|
\t}
|
||||||
\tif (itemType === 'boolean') {
|
\tif (itemType === 'boolean') {
|
||||||
\t\treturn values.map((entry) => entry !== 'false');
|
\t\treturn values.map((entry) => entry !== 'false');
|
||||||
@ -243,6 +246,15 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
|
|||||||
\treturn values;
|
\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) {
|
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
||||||
\tconst base = { ...server } as Record<string, unknown>;
|
\tconst base = { ...server } as Record<string, unknown>;
|
||||||
\tif ((server.command as any).kind === 'http') {
|
\tif ((server.command as any).kind === 'http') {
|
||||||
@ -281,7 +293,7 @@ function determineArtifactKind(): 'template' | 'bundle' | 'binary' {
|
|||||||
\tif (scriptPath.endsWith('.ts')) {
|
\tif (scriptPath.endsWith('.ts')) {
|
||||||
\t\treturn 'template';
|
\t\treturn 'template';
|
||||||
\t}
|
\t}
|
||||||
\tif (scriptPath.endsWith('.js')) {
|
\tif (scriptPath.endsWith('.js') || scriptPath.endsWith('.mjs') || scriptPath.endsWith('.cjs')) {
|
||||||
\t\treturn 'bundle';
|
\t\treturn 'bundle';
|
||||||
\t}
|
\t}
|
||||||
\treturn 'binary';
|
\treturn 'binary';
|
||||||
@ -460,7 +472,9 @@ export function renderToolCommand(
|
|||||||
({ option, camelCaseProp }) =>
|
({ option, camelCaseProp }) =>
|
||||||
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
|
`{ 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\tif (missingRequired.length > 0) {
|
||||||
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
||||||
\t\t\t}`
|
\t\t\t}`
|
||||||
@ -547,7 +561,7 @@ export const templateTestHelpers = { computeRelativeStdioCwd };
|
|||||||
function optionParser(option: GeneratedOption): string | undefined {
|
function optionParser(option: GeneratedOption): string | undefined {
|
||||||
switch (option.type) {
|
switch (option.type) {
|
||||||
case 'number':
|
case 'number':
|
||||||
return '(value) => parseFloat(value)';
|
return '(value) => parseFiniteNumber(value)';
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return "(value) => value !== 'false'";
|
return "(value) => value !== 'false'";
|
||||||
case 'object':
|
case 'object':
|
||||||
@ -568,3 +582,16 @@ function optionParser(option: GeneratedOption): string | undefined {
|
|||||||
return 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> {
|
export function buildEmbeddedSchemaMap(tools: ToolMetadata[]): Record<string, unknown> {
|
||||||
const result: 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') {
|
if (entry.tool.inputSchema && typeof entry.tool.inputSchema === 'object') {
|
||||||
result[entry.tool.name] = entry.tool.inputSchema;
|
result[entry.tool.name] = entry.tool.inputSchema;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,21 @@ function buildCommandSections(colorize: boolean): string[] {
|
|||||||
summary: 'Complete OAuth for a server without listing tools',
|
summary: 'Complete OAuth for a server without listing tools',
|
||||||
usage: 'mcporter auth <server | url> [--reset]',
|
usage: 'mcporter auth <server | url> [--reset]',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'vault',
|
||||||
|
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>]',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -107,6 +122,11 @@ function buildCommandSections(colorize: boolean): string[] {
|
|||||||
summary: 'Manage the keep-alive daemon (start | status | stop | restart)',
|
summary: 'Manage the keep-alive daemon (start | status | stop | restart)',
|
||||||
usage: 'mcporter daemon <subcommand>',
|
usage: 'mcporter daemon <subcommand>',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'serve',
|
||||||
|
summary: 'Expose daemon-managed keep-alive servers as one MCP server',
|
||||||
|
usage: 'mcporter serve [--servers a,b,c] [--stdio | --http <port>]',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -93,6 +93,9 @@ function parseInspectFlags(args: string[]): InspectFlags {
|
|||||||
if (!artifactPath) {
|
if (!artifactPath) {
|
||||||
throw new Error('Usage: mcporter inspect-cli <artifact> [--json]');
|
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 };
|
return { artifactPath, format };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,9 @@ export async function handleList(
|
|||||||
const perServerTimeoutSeconds = Math.round(perServerTimeoutMs / 1000);
|
const perServerTimeoutSeconds = Math.round(perServerTimeoutMs / 1000);
|
||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
|
if (flags.quiet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (flags.format === 'json') {
|
if (flags.format === 'json') {
|
||||||
const payload = {
|
const payload = {
|
||||||
mode: 'list',
|
mode: 'list',
|
||||||
@ -73,17 +76,17 @@ export async function handleList(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flags.format === 'text') {
|
if (!flags.quiet && flags.format === 'text') {
|
||||||
console.log(
|
console.log(
|
||||||
`mcporter ${MCPORTER_VERSION} — Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`
|
`mcporter ${MCPORTER_VERSION} — Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const spinner =
|
const spinner =
|
||||||
flags.format === 'text' && supportsSpinner
|
!flags.quiet && flags.format === 'text' && supportsSpinner
|
||||||
? ora(`Discovering ${servers.length} server(s)…`).start()
|
? ora(`Discovering ${servers.length} server(s)…`).start()
|
||||||
: undefined;
|
: undefined;
|
||||||
const renderedResults =
|
const renderedResults =
|
||||||
flags.format === 'text'
|
!flags.quiet && flags.format === 'text'
|
||||||
? (Array.from({ length: servers.length }, () => undefined) as Array<
|
? (Array.from({ length: servers.length }, () => undefined) as Array<
|
||||||
ReturnType<typeof renderServerListRow> | undefined
|
ReturnType<typeof renderServerListRow> | undefined
|
||||||
>)
|
>)
|
||||||
@ -95,28 +98,7 @@ export async function handleList(
|
|||||||
let completedCount = 0;
|
let completedCount = 0;
|
||||||
|
|
||||||
const tasks = servers.map((server, index) =>
|
const tasks = servers.map((server, index) =>
|
||||||
(async (): Promise<ListSummaryResult> => {
|
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
|
||||||
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) => {
|
|
||||||
summaryResults[index] = result;
|
summaryResults[index] = result;
|
||||||
if (renderedResults) {
|
if (renderedResults) {
|
||||||
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
||||||
@ -139,20 +121,25 @@ export async function handleList(
|
|||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(tasks);
|
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') {
|
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));
|
console.log(JSON.stringify({ mode: 'list', counts, servers: jsonEntries }, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -160,21 +147,13 @@ export async function handleList(
|
|||||||
if (spinner) {
|
if (spinner) {
|
||||||
spinner.stop();
|
spinner.stop();
|
||||||
}
|
}
|
||||||
const errorCounts = createEmptyStatusCounts();
|
const okSummary = `${counts.ok} healthy`;
|
||||||
renderedResults?.forEach((entry) => {
|
|
||||||
if (!entry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const category = entry.category ?? 'error';
|
|
||||||
errorCounts[category] = (errorCounts[category] ?? 0) + 1;
|
|
||||||
});
|
|
||||||
const okSummary = `${errorCounts.ok} healthy`;
|
|
||||||
const parts = [
|
const parts = [
|
||||||
okSummary,
|
okSummary,
|
||||||
...(errorCounts.auth > 0 ? [`${errorCounts.auth} auth required`] : []),
|
...(counts.auth > 0 ? [`${counts.auth} auth required`] : []),
|
||||||
...(errorCounts.offline > 0 ? [`${errorCounts.offline} offline`] : []),
|
...(counts.offline > 0 ? [`${counts.offline} offline`] : []),
|
||||||
...(errorCounts.http > 0 ? [`${errorCounts.http} http errors`] : []),
|
...(counts.http > 0 ? [`${counts.http} http errors`] : []),
|
||||||
...(errorCounts.error > 0 ? [`${errorCounts.error} errors`] : []),
|
...(counts.error > 0 ? [`${counts.error} errors`] : []),
|
||||||
];
|
];
|
||||||
console.log(`✔ Listed ${servers.length} server${servers.length === 1 ? '' : 's'} (${parts.join('; ')}).`);
|
console.log(`✔ Listed ${servers.length} server${servers.length === 1 ? '' : 's'} (${parts.join('; ')}).`);
|
||||||
return;
|
return;
|
||||||
@ -190,9 +169,13 @@ export async function handleList(
|
|||||||
requestedTool = selector.tool;
|
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) {
|
if (!resolved) {
|
||||||
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target = resolved.name;
|
target = resolved.name;
|
||||||
@ -204,14 +187,119 @@ export async function handleList(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const transportSummary = formatTransportSummary(definition);
|
const transportSummary = formatTransportSummary(definition);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
if (flags.format === 'json') {
|
if (flags.statusOnly) {
|
||||||
|
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
||||||
try {
|
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(
|
const metadataEntries = filterToolMetadata(
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
loadToolMetadata(runtime, target, {
|
loadToolMetadata(runtime, target, {
|
||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: flags.disableOAuth,
|
||||||
}),
|
}),
|
||||||
timeoutMs
|
timeoutMs
|
||||||
),
|
),
|
||||||
@ -220,96 +308,62 @@ export async function handleList(
|
|||||||
await persistPreparedEphemeralServer(runtime, prepared);
|
await persistPreparedEphemeralServer(runtime, prepared);
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
if (requestedTool && metadataEntries.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const instructions = await loadServerInstructions(runtime, target);
|
const instructions = await loadServerInstructions(runtime, target);
|
||||||
const payload = {
|
const summaryLine = printSingleServerHeader(
|
||||||
mode: 'server',
|
definition,
|
||||||
name: definition.name,
|
metadataEntries.length,
|
||||||
status: 'ok' as StatusCategory,
|
|
||||||
durationMs,
|
durationMs,
|
||||||
description: definition.description,
|
transportSummary,
|
||||||
instructions,
|
sourcePath,
|
||||||
transport: transportSummary,
|
{
|
||||||
source: definition.source,
|
printSummaryNow: false,
|
||||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
instructions,
|
||||||
tools: metadataEntries.map((entry) => ({
|
}
|
||||||
name: entry.tool.name,
|
);
|
||||||
description: entry.tool.description,
|
if (metadataEntries.length === 0) {
|
||||||
inputSchema: entry.tool.inputSchema,
|
console.log(' Tools: <none>');
|
||||||
outputSchema: entry.tool.outputSchema,
|
console.log(summaryLine);
|
||||||
options: entry.options,
|
console.log('');
|
||||||
})),
|
return;
|
||||||
};
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
);
|
if (flags.brief) {
|
||||||
if (metadataEntries.length === 0) {
|
let optionalOmitted = false;
|
||||||
console.log(' Tools: <none>');
|
for (const entry of metadataEntries) {
|
||||||
console.log(summaryLine);
|
const detail = printBriefTool(definition, entry, flags.requiredOnly);
|
||||||
console.log('');
|
optionalOmitted ||= detail.optionalOmitted;
|
||||||
return;
|
}
|
||||||
}
|
if (flags.requiredOnly && optionalOmitted) {
|
||||||
if (flags.brief) {
|
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;
|
let optionalOmitted = false;
|
||||||
for (const entry of metadataEntries) {
|
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;
|
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) {
|
if (flags.requiredOnly && optionalOmitted) {
|
||||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@ -317,42 +371,85 @@ export async function handleList(
|
|||||||
console.log(summaryLine);
|
console.log(summaryLine);
|
||||||
console.log('');
|
console.log('');
|
||||||
return;
|
return;
|
||||||
}
|
} catch (error) {
|
||||||
const examples: string[] = [];
|
await persistPreparedEphemeralServer(runtime, prepared);
|
||||||
let optionalOmitted = false;
|
maybeSetListExitCode([{ status: 'error' }], flags);
|
||||||
for (const entry of metadataEntries) {
|
if (flags.quiet) {
|
||||||
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
|
return;
|
||||||
examples.push(...detail.examples);
|
}
|
||||||
optionalOmitted ||= detail.optionalOmitted;
|
const durationMs = Date.now() - startedAt;
|
||||||
}
|
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
|
||||||
const uniqueExamples = formatExampleBlock(examples);
|
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
|
||||||
if (uniqueExamples.length > 0) {
|
const authCommand = buildAuthCommandHint(definition);
|
||||||
console.log(` ${dimText('Examples:')}`);
|
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||||
for (const example of uniqueExamples) {
|
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
|
||||||
console.log(` ${example}`);
|
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) {
|
} finally {
|
||||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
if (previousStdioLogMode !== undefined) {
|
||||||
console.log('');
|
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) {
|
} catch (error) {
|
||||||
await persistPreparedEphemeralServer(runtime, prepared);
|
return {
|
||||||
const durationMs = Date.now() - startedAt;
|
server,
|
||||||
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
|
status: 'error' as const,
|
||||||
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
|
error,
|
||||||
const authCommand = buildAuthCommandHint(definition);
|
durationMs: Date.now() - startedAt,
|
||||||
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) {
|
function maybeSetListExitCode(
|
||||||
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
|
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.',
|
' --schema Show tool schemas when listing servers.',
|
||||||
' --all-parameters Include optional parameters in tool docs.',
|
' --all-parameters Include optional parameters in tool docs.',
|
||||||
' --json Emit a JSON summary instead of text.',
|
' --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.',
|
' --verbose Show all config sources for matching servers.',
|
||||||
' --sources Include source arrays in JSON output without other verbose details.',
|
' --sources Include source arrays in JSON output without other verbose details.',
|
||||||
' --timeout <ms> Override the per-server discovery timeout.',
|
' --timeout <ms> Override the per-server discovery timeout.',
|
||||||
|
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||||
'',
|
'',
|
||||||
'Examples:',
|
'Examples:',
|
||||||
' mcporter list',
|
' mcporter list',
|
||||||
|
' mcporter list --quiet',
|
||||||
' mcporter list linear --schema',
|
' mcporter list linear --schema',
|
||||||
|
' mcporter list linear --status --json',
|
||||||
' mcporter list linear --brief',
|
' mcporter list linear --brief',
|
||||||
' mcporter list linear.list_issues --signatures',
|
' mcporter list linear.list_issues --signatures',
|
||||||
' mcporter list https://mcp.example.com/mcp',
|
' mcporter list https://mcp.example.com/mcp',
|
||||||
@ -400,7 +503,8 @@ export function printListHelp(): void {
|
|||||||
|
|
||||||
function resolveServerDefinition(
|
function resolveServerDefinition(
|
||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
name: string
|
name: string,
|
||||||
|
options: { quiet?: boolean } = {}
|
||||||
): { definition: ServerDefinition; name: string } | undefined {
|
): { definition: ServerDefinition; name: string } | undefined {
|
||||||
try {
|
try {
|
||||||
const definition = runtime.getDefinition(name);
|
const definition = runtime.getDefinition(name);
|
||||||
@ -411,7 +515,9 @@ function resolveServerDefinition(
|
|||||||
}
|
}
|
||||||
const suggestion = suggestServerName(runtime, name);
|
const suggestion = suggestServerName(runtime, name);
|
||||||
if (!suggestion) {
|
if (!suggestion) {
|
||||||
console.error(error.message);
|
if (!options.quiet) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const messages = renderIdentifierResolutionMessages({
|
const messages = renderIdentifierResolutionMessages({
|
||||||
@ -420,13 +526,17 @@ function resolveServerDefinition(
|
|||||||
resolution: suggestion,
|
resolution: suggestion,
|
||||||
});
|
});
|
||||||
if (suggestion.kind === 'auto' && messages.auto) {
|
if (suggestion.kind === 'auto' && messages.auto) {
|
||||||
console.log(dimText(messages.auto));
|
if (!options.quiet) {
|
||||||
return resolveServerDefinition(runtime, suggestion.value);
|
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(yellowText(messages.suggest));
|
||||||
}
|
}
|
||||||
console.error(error.message);
|
if (!options.quiet) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,10 @@ export function extractListFlags(args: string[]): {
|
|||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
includeSources: boolean;
|
includeSources: boolean;
|
||||||
brief: boolean;
|
brief: boolean;
|
||||||
|
quiet: boolean;
|
||||||
|
exitCode: boolean;
|
||||||
|
statusOnly: boolean;
|
||||||
|
disableOAuth: boolean;
|
||||||
} {
|
} {
|
||||||
let schema = false;
|
let schema = false;
|
||||||
let timeoutMs: number | undefined;
|
let timeoutMs: number | undefined;
|
||||||
@ -21,6 +25,10 @@ export function extractListFlags(args: string[]): {
|
|||||||
let verbose = false;
|
let verbose = false;
|
||||||
let includeSources = false;
|
let includeSources = false;
|
||||||
let brief = false;
|
let brief = false;
|
||||||
|
let quiet = false;
|
||||||
|
let exitCode = false;
|
||||||
|
let statusOnly = false;
|
||||||
|
let disableOAuth = false;
|
||||||
const format = consumeOutputFormat(args, {
|
const format = consumeOutputFormat(args, {
|
||||||
defaultFormat: 'text',
|
defaultFormat: 'text',
|
||||||
allowed: ['text', 'json'],
|
allowed: ['text', 'json'],
|
||||||
@ -60,6 +68,27 @@ export function extractListFlags(args: string[]): {
|
|||||||
args.splice(index, 1);
|
args.splice(index, 1);
|
||||||
continue;
|
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') {
|
if (token === '--timeout') {
|
||||||
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
||||||
continue;
|
continue;
|
||||||
@ -84,5 +113,33 @@ export function extractListFlags(args: string[]): {
|
|||||||
throw new Error(`--brief cannot be used with ${conflicts.join(', ')}`);
|
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)) {
|
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
|
||||||
return segment;
|
return segment;
|
||||||
}
|
}
|
||||||
return JSON.stringify(segment);
|
return `'${segment.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { inspect } from 'node:util';
|
import { inspect } from 'node:util';
|
||||||
import type { CallResult } from '../result-utils.js';
|
import type { CallResult } from '../result-utils.js';
|
||||||
import { logWarn } from './logger-context.js';
|
import { logWarn } from './logger-context.js';
|
||||||
@ -33,17 +34,8 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const candidates: string[] = [];
|
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') {
|
if (result && typeof result === 'object') {
|
||||||
const possibleKeys = ['logPath', 'logFile', 'logfile', 'path'];
|
const possibleKeys = ['logPath', 'logFile', 'logfile'];
|
||||||
for (const key of possibleKeys) {
|
for (const key of possibleKeys) {
|
||||||
const value = (result as Record<string, unknown>)[key];
|
const value = (result as Record<string, unknown>)[key];
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@ -53,6 +45,10 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
|
if (!path.isAbsolute(candidate)) {
|
||||||
|
logWarn(`Refusing to tail non-absolute log path: ${candidate}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!fs.existsSync(candidate)) {
|
if (!fs.existsSync(candidate)) {
|
||||||
logWarn(`Log path not found: ${candidate}`);
|
logWarn(`Log path not found: ${candidate}`);
|
||||||
continue;
|
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,
|
enableRawShortcut: true,
|
||||||
jsonShortcutFlag: '--json',
|
jsonShortcutFlag: '--json',
|
||||||
});
|
});
|
||||||
|
const disableOAuth = consumeDisableOAuthFlag(args);
|
||||||
const server = args.shift();
|
const server = args.shift();
|
||||||
if (!server) {
|
if (!server) {
|
||||||
throw new Error('Missing server name. Usage: mcporter resource <server> [uri]');
|
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;
|
let result: unknown;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
const issue = analyzeConnectionError(error);
|
const issue = analyzeConnectionError(error);
|
||||||
if (output === 'json' || output === 'raw') {
|
if (output === 'json' || output === 'raw') {
|
||||||
@ -39,6 +47,20 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
|||||||
printCallOutput(callResult, result, output);
|
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 {
|
export function printResourceHelp(): void {
|
||||||
console.error(
|
console.error(
|
||||||
[
|
[
|
||||||
@ -51,6 +73,7 @@ export function printResourceHelp(): void {
|
|||||||
' --output auto|text|markdown|json|raw Choose output rendering.',
|
' --output auto|text|markdown|json|raw Choose output rendering.',
|
||||||
' --json Shortcut for --output json.',
|
' --json Shortcut for --output json.',
|
||||||
' --raw Shortcut for --output raw.',
|
' --raw Shortcut for --output raw.',
|
||||||
|
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||||
'',
|
'',
|
||||||
'Examples:',
|
'Examples:',
|
||||||
' mcporter resource docs',
|
' mcporter resource docs',
|
||||||
|
|||||||
197
src/cli/serve-command.ts
Normal file
197
src/cli/serve-command.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { DaemonClient } from '../daemon/client.js';
|
||||||
|
import { createKeepAliveRuntime } from '../daemon/runtime-wrapper.js';
|
||||||
|
import { isKeepAliveServer } from '../lifecycle.js';
|
||||||
|
import { createRuntime } from '../runtime.js';
|
||||||
|
import { DEFAULT_SERVE_HTTP_HOST, selectServedServers, serveHttp, serveStdio } from '../serve.js';
|
||||||
|
|
||||||
|
interface ServeCliOptions {
|
||||||
|
readonly configPath: string;
|
||||||
|
readonly configExplicit?: boolean;
|
||||||
|
readonly rootDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedServeArgs {
|
||||||
|
readonly mode: 'stdio' | 'http';
|
||||||
|
readonly port?: number;
|
||||||
|
readonly host?: string;
|
||||||
|
readonly servers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleServeCli(args: string[], options: ServeCliOptions): Promise<void> {
|
||||||
|
const parsed = parseServeArgs(args);
|
||||||
|
const baseRuntime = await createRuntime({
|
||||||
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
|
rootDir: options.rootDir,
|
||||||
|
});
|
||||||
|
const definitions = baseRuntime.getDefinitions();
|
||||||
|
|
||||||
|
const keepAliveServers = new Set(definitions.filter(isKeepAliveServer).map((definition) => definition.name));
|
||||||
|
let selectedServers: string[];
|
||||||
|
try {
|
||||||
|
const servedServers = selectServedServers(definitions, parsed.servers);
|
||||||
|
selectedServers = servedServers.map((server) => server.name);
|
||||||
|
if (selectedServers.length === 0) {
|
||||||
|
throw new Error('No MCP servers are configured for keep-alive; nothing to serve.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await baseRuntime.close().catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemonClient = new DaemonClient({
|
||||||
|
configPath: options.configPath,
|
||||||
|
configExplicit: options.configExplicit,
|
||||||
|
rootDir: options.rootDir,
|
||||||
|
});
|
||||||
|
const runtime = createKeepAliveRuntime(baseRuntime, {
|
||||||
|
daemonClient,
|
||||||
|
keepAliveServers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsed.mode === 'http') {
|
||||||
|
let server: Awaited<ReturnType<typeof serveHttp>>;
|
||||||
|
try {
|
||||||
|
server = await serveHttp({
|
||||||
|
runtime,
|
||||||
|
definitions,
|
||||||
|
servers: selectedServers,
|
||||||
|
port: parsed.port ?? 0,
|
||||||
|
host: parsed.host,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await runtime.close().catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
server.once('close', () => {
|
||||||
|
void runtime.close().catch(() => {});
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
const location =
|
||||||
|
typeof address === 'object' && address
|
||||||
|
? `http://${address.address === '::' ? 'localhost' : address.address}:${address.port}/mcp`
|
||||||
|
: 'listening';
|
||||||
|
console.error(`MCPorter serve HTTP bridge ${location}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serveStdio({
|
||||||
|
runtime,
|
||||||
|
definitions,
|
||||||
|
servers: selectedServers,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await runtime.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printServeHelp(): void {
|
||||||
|
console.log(`Usage: mcporter serve [--servers a,b,c] [--stdio | --http <port>]
|
||||||
|
|
||||||
|
Expose daemon-managed keep-alive MCP servers as one MCP server.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--servers <csv> Restrict the bridge to the listed keep-alive server names.
|
||||||
|
--stdio Serve MCP over stdio (default).
|
||||||
|
--http <port> Serve MCP Streamable HTTP on /mcp and /mcp/<server>.
|
||||||
|
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServeArgs(args: string[]): ParsedServeArgs {
|
||||||
|
let mode: 'stdio' | 'http' = 'stdio';
|
||||||
|
let port: number | undefined;
|
||||||
|
let host: string | undefined;
|
||||||
|
let servers: string[] | undefined;
|
||||||
|
let explicitStdio = false;
|
||||||
|
let explicitHttp = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const token = args[index];
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === '--stdio') {
|
||||||
|
explicitStdio = true;
|
||||||
|
mode = 'stdio';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === '--http') {
|
||||||
|
explicitHttp = true;
|
||||||
|
mode = 'http';
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Flag '--http' requires a port.");
|
||||||
|
}
|
||||||
|
port = parsePort(value);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.startsWith('--http=')) {
|
||||||
|
explicitHttp = true;
|
||||||
|
mode = 'http';
|
||||||
|
port = parsePort(token.slice('--http='.length));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === '--servers') {
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Flag '--servers' requires a comma-separated list.");
|
||||||
|
}
|
||||||
|
servers = parseServerList(value);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.startsWith('--servers=')) {
|
||||||
|
servers = parseServerList(token.slice('--servers='.length));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === '--host') {
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Flag '--host' requires a value.");
|
||||||
|
}
|
||||||
|
host = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.startsWith('--host=')) {
|
||||||
|
host = token.slice('--host='.length);
|
||||||
|
if (!host) {
|
||||||
|
throw new Error("Flag '--host' requires a value.");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown serve flag '${token}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitStdio && explicitHttp) {
|
||||||
|
throw new Error("Flags '--stdio' and '--http' cannot be used together.");
|
||||||
|
}
|
||||||
|
if (host && mode !== 'http') {
|
||||||
|
throw new Error("Flag '--host' can only be used with '--http'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, port, host, servers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(value: string): number {
|
||||||
|
if (value.trim().length === 0) {
|
||||||
|
throw new Error("Flag '--http' requires a port.");
|
||||||
|
}
|
||||||
|
const port = Number(value);
|
||||||
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
||||||
|
throw new Error(`Invalid HTTP port '${value}'.`);
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerList(value: string): string[] {
|
||||||
|
const servers = value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
if (servers.length === 0) {
|
||||||
|
throw new Error("Flag '--servers' requires at least one server name.");
|
||||||
|
}
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
@ -1,16 +1,21 @@
|
|||||||
const DEFAULT_LIST_TIMEOUT_MS = 30_000;
|
const DEFAULT_LIST_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_CALL_TIMEOUT_MS = 60_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.
|
// parseTimeout reads timeout values from strings while honoring defaults.
|
||||||
export function parseTimeout(raw: string | undefined, fallback: number): number {
|
export function parseTimeout(raw: string | undefined, fallback: number): number {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(raw, 10);
|
return parsePositiveInteger(raw) ?? fallback;
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
|
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
|
||||||
@ -58,8 +63,8 @@ export function consumeTimeoutFlag(
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(missingValueMessage);
|
throw new Error(missingValueMessage);
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = parsePositiveInteger(value);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (parsed === undefined) {
|
||||||
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
|
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
|
||||||
}
|
}
|
||||||
args.splice(index, 2);
|
args.splice(index, 2);
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import type { ListToolsOptions, Runtime } from '../runtime.js';
|
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 {
|
interface LoadToolMetadataOptions {
|
||||||
includeSchema?: boolean;
|
includeSchema?: boolean;
|
||||||
autoAuthorize?: boolean;
|
autoAuthorize?: boolean;
|
||||||
allowCachedAuth?: boolean;
|
allowCachedAuth?: boolean;
|
||||||
|
disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
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 {
|
function cacheKey(serverName: string, options: LoadToolMetadataOptions): string {
|
||||||
const includeSchema = options.includeSchema !== false;
|
const includeSchema = options.includeSchema !== false;
|
||||||
const autoAuthorize = options.autoAuthorize !== false;
|
const autoAuthorize = options.autoAuthorize !== false;
|
||||||
const allowCachedAuth = options.allowCachedAuth === true;
|
const allowCachedAuth = options.allowCachedAuth !== false;
|
||||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}`;
|
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(
|
export async function loadToolMetadata(
|
||||||
@ -33,13 +35,15 @@ export async function loadToolMetadata(
|
|||||||
}
|
}
|
||||||
const includeSchema = options.includeSchema !== false;
|
const includeSchema = options.includeSchema !== false;
|
||||||
const autoAuthorize = options.autoAuthorize !== false;
|
const autoAuthorize = options.autoAuthorize !== false;
|
||||||
const listOptions: ListToolsOptions =
|
const listOptions: ListToolsOptions = {
|
||||||
options.allowCachedAuth === undefined
|
includeSchema,
|
||||||
? { includeSchema, autoAuthorize }
|
autoAuthorize,
|
||||||
: { includeSchema, autoAuthorize, allowCachedAuth: options.allowCachedAuth };
|
allowCachedAuth: options.allowCachedAuth ?? true,
|
||||||
|
disableOAuth: options.disableOAuth,
|
||||||
|
};
|
||||||
const promise = runtime
|
const promise = runtime
|
||||||
.listTools(serverName, listOptions)
|
.listTools(serverName, listOptions)
|
||||||
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
|
.then((tools) => buildToolMetadataList(tools, { sort: false }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
cache?.delete(key);
|
cache?.delete(key);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
175
src/cli/vault-command.ts
Normal file
175
src/cli/vault-command.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
|
import type { Runtime } from '../runtime.js';
|
||||||
|
import { clearVaultEntry, getOAuthVaultPath, saveVaultEntry } from '../oauth-vault.js';
|
||||||
|
import { CliUsageError } from './errors.js';
|
||||||
|
|
||||||
|
interface VaultPayload {
|
||||||
|
readonly tokens: OAuthTokens;
|
||||||
|
readonly clientInfo?: OAuthClientInformationMixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultCommandOptions {
|
||||||
|
readonly readStdin?: () => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleVault(
|
||||||
|
runtime: Pick<Runtime, 'getDefinition'>,
|
||||||
|
args: string[],
|
||||||
|
options: VaultCommandOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const subcommand = args.shift();
|
||||||
|
if (subcommand === 'set') {
|
||||||
|
await handleVaultSet(runtime, args, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subcommand === 'clear') {
|
||||||
|
await handleVaultClear(runtime, args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new CliUsageError('Usage: mcporter vault <set|clear> ...');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVaultSet(
|
||||||
|
runtime: Pick<Runtime, 'getDefinition'>,
|
||||||
|
args: string[],
|
||||||
|
options: VaultCommandOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const server = args.shift();
|
||||||
|
if (!server) {
|
||||||
|
throw new CliUsageError('Usage: mcporter vault set <server> (--tokens-file <path> | --stdin)');
|
||||||
|
}
|
||||||
|
const source = consumeVaultPayloadSource(args);
|
||||||
|
if (args.length > 0) {
|
||||||
|
throw new CliUsageError(`Unknown vault set argument '${args[0]}'.`);
|
||||||
|
}
|
||||||
|
const definition = runtime.getDefinition(server);
|
||||||
|
const payload = validateVaultPayload(JSON.parse(await readPayload(source, options)));
|
||||||
|
await saveVaultEntry(definition, {
|
||||||
|
tokens: payload.tokens,
|
||||||
|
...(payload.clientInfo ? { clientInfo: payload.clientInfo } : {}),
|
||||||
|
});
|
||||||
|
console.log(`Saved OAuth credentials for '${definition.name}' to ${getOAuthVaultPath()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVaultClear(runtime: Pick<Runtime, 'getDefinition'>, args: string[]): Promise<void> {
|
||||||
|
const server = args.shift();
|
||||||
|
if (!server) {
|
||||||
|
throw new CliUsageError('Usage: mcporter vault clear <server>');
|
||||||
|
}
|
||||||
|
if (args.length > 0) {
|
||||||
|
throw new CliUsageError(`Unknown vault clear argument '${args[0]}'.`);
|
||||||
|
}
|
||||||
|
const definition = runtime.getDefinition(server);
|
||||||
|
await clearVaultEntry(definition, 'all');
|
||||||
|
console.log(`Cleared OAuth vault entry for '${definition.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeVaultPayloadSource(args: string[]): { kind: 'file'; path: string } | { kind: 'stdin' } {
|
||||||
|
const fileIndex = args.indexOf('--tokens-file');
|
||||||
|
const stdinIndex = args.indexOf('--stdin');
|
||||||
|
if (fileIndex !== -1 && stdinIndex !== -1) {
|
||||||
|
throw new CliUsageError("Use either '--tokens-file' or '--stdin', not both.");
|
||||||
|
}
|
||||||
|
if (fileIndex !== -1) {
|
||||||
|
const filePath = args[fileIndex + 1];
|
||||||
|
if (!filePath) {
|
||||||
|
throw new CliUsageError("Flag '--tokens-file' requires a path.");
|
||||||
|
}
|
||||||
|
args.splice(fileIndex, 2);
|
||||||
|
return { kind: 'file', path: filePath };
|
||||||
|
}
|
||||||
|
if (stdinIndex !== -1) {
|
||||||
|
args.splice(stdinIndex, 1);
|
||||||
|
return { kind: 'stdin' };
|
||||||
|
}
|
||||||
|
throw new CliUsageError('Usage: mcporter vault set <server> (--tokens-file <path> | --stdin)');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(
|
||||||
|
source: { kind: 'file'; path: string } | { kind: 'stdin' },
|
||||||
|
options: VaultCommandOptions
|
||||||
|
): Promise<string> {
|
||||||
|
if (source.kind === 'file') {
|
||||||
|
return fs.readFile(source.path, 'utf8');
|
||||||
|
}
|
||||||
|
if (options.readStdin) {
|
||||||
|
return options.readStdin();
|
||||||
|
}
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let data = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
process.stdin.on('end', () => resolve(data));
|
||||||
|
process.stdin.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVaultPayload(value: unknown): VaultPayload {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
throw new CliUsageError('Vault payload must be a JSON object.');
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (!record.tokens || typeof record.tokens !== 'object' || Array.isArray(record.tokens)) {
|
||||||
|
throw new CliUsageError("Vault payload must include a 'tokens' object.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
record.clientInfo !== undefined &&
|
||||||
|
(!record.clientInfo || typeof record.clientInfo !== 'object' || Array.isArray(record.clientInfo))
|
||||||
|
) {
|
||||||
|
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> ...',
|
||||||
|
'',
|
||||||
|
'Commands:',
|
||||||
|
' vault set <server> --tokens-file <path> Seed OAuth tokens from JSON.',
|
||||||
|
' vault set <server> --stdin Seed OAuth tokens from stdin JSON.',
|
||||||
|
' vault clear <server> Remove the server entry from the OAuth vault.',
|
||||||
|
'',
|
||||||
|
'Payload:',
|
||||||
|
' { "tokens": { "access_token": "...", "token_type": "Bearer" }, "clientInfo": { "client_id": "..." } }',
|
||||||
|
];
|
||||||
|
console.error(lines.join('\n'));
|
||||||
|
}
|
||||||
@ -1,7 +1,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
|
import type {
|
||||||
import { expandHome } from './env.js';
|
CommandSpec,
|
||||||
|
RawEntry,
|
||||||
|
RawRefresh,
|
||||||
|
RefreshableBearerOptions,
|
||||||
|
ServerDefinition,
|
||||||
|
ServerLoggingOptions,
|
||||||
|
ServerSource,
|
||||||
|
} from './config-schema.js';
|
||||||
|
import { expandHome, resolveEnvPlaceholders } from './env.js';
|
||||||
import { resolveLifecycle } from './lifecycle.js';
|
import { resolveLifecycle } from './lifecycle.js';
|
||||||
|
|
||||||
export function normalizeServerEntry(
|
export function normalizeServerEntry(
|
||||||
@ -11,6 +19,8 @@ export function normalizeServerEntry(
|
|||||||
source: ServerSource,
|
source: ServerSource,
|
||||||
sources: readonly ServerSource[]
|
sources: readonly ServerSource[]
|
||||||
): ServerDefinition {
|
): ServerDefinition {
|
||||||
|
const resolvedRaw = resolveConfigEnvPlaceholders(name, raw);
|
||||||
|
raw = resolvedRaw;
|
||||||
const description = raw.description;
|
const description = raw.description;
|
||||||
const env = raw.env ? { ...raw.env } : undefined;
|
const env = raw.env ? { ...raw.env } : undefined;
|
||||||
const auth = normalizeAuth(raw.auth);
|
const auth = normalizeAuth(raw.auth);
|
||||||
@ -23,6 +33,8 @@ export function normalizeServerEntry(
|
|||||||
raw.oauthTokenEndpointAuthMethod ?? raw.oauth_token_endpoint_auth_method ?? undefined;
|
raw.oauthTokenEndpointAuthMethod ?? raw.oauth_token_endpoint_auth_method ?? undefined;
|
||||||
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;
|
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;
|
||||||
const oauthScope = raw.oauthScope ?? raw.oauth_scope ?? undefined;
|
const oauthScope = raw.oauthScope ?? raw.oauth_scope ?? undefined;
|
||||||
|
const refresh = normalizeRefresh(raw.refresh);
|
||||||
|
const httpFetch = normalizeHttpFetch(raw.httpFetch ?? raw.http_fetch);
|
||||||
const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
|
const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
|
||||||
const oauthCommand = oauthCommandRaw ? { args: [...oauthCommandRaw.args] } : undefined;
|
const oauthCommand = oauthCommandRaw ? { args: [...oauthCommandRaw.args] } : undefined;
|
||||||
const headers = buildHeaders(raw);
|
const headers = buildHeaders(raw);
|
||||||
@ -74,6 +86,8 @@ export function normalizeServerEntry(
|
|||||||
oauthRedirectUrl,
|
oauthRedirectUrl,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
oauthCommand: defaultedOauthCommand,
|
oauthCommand: defaultedOauthCommand,
|
||||||
|
refresh,
|
||||||
|
httpFetch,
|
||||||
source,
|
source,
|
||||||
sources,
|
sources,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
@ -85,8 +99,55 @@ export function normalizeServerEntry(
|
|||||||
|
|
||||||
export const __configInternals = {
|
export const __configInternals = {
|
||||||
ensureHttpAcceptHeader,
|
ensureHttpAcceptHeader,
|
||||||
|
resolveConfigEnvPlaceholders,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveConfigEnvPlaceholders(name: string, raw: RawEntry): RawEntry {
|
||||||
|
return resolveConfigEnvValue(name, raw, []) as RawEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigEnvValue(name: string, value: unknown, pathSegments: readonly string[]): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.includes('$') || shouldDeferEnvResolution(pathSegments)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return resolveEnvPlaceholders(value);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const field = pathSegments.join('.') || '<root>';
|
||||||
|
throw new Error(`Server '${name}' field '${field}' has unresolved env placeholder: ${message}`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry, index) => resolveConfigEnvValue(name, entry, [...pathSegments, String(index)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
resolved[key] = resolveConfigEnvValue(name, entry, [...pathSegments, key]);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldDeferEnvResolution(pathSegments: readonly string[]): boolean {
|
||||||
|
const [root] = pathSegments;
|
||||||
|
const field = pathSegments.at(-1) ?? '';
|
||||||
|
return (
|
||||||
|
root === 'headers' ||
|
||||||
|
root === 'env' ||
|
||||||
|
field === 'bearerToken' ||
|
||||||
|
field === 'bearer_token' ||
|
||||||
|
field.endsWith('Env') ||
|
||||||
|
field.endsWith('_env')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuth(auth: string | undefined): string | undefined {
|
function normalizeAuth(auth: string | undefined): string | undefined {
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -94,9 +155,31 @@ function normalizeAuth(auth: string | undefined): string | undefined {
|
|||||||
if (auth.toLowerCase() === 'oauth') {
|
if (auth.toLowerCase() === 'oauth') {
|
||||||
return 'oauth';
|
return 'oauth';
|
||||||
}
|
}
|
||||||
|
if (auth.toLowerCase() === 'refreshable_bearer') {
|
||||||
|
return 'refreshable_bearer';
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRefresh(raw: RawRefresh | undefined): RefreshableBearerOptions | undefined {
|
||||||
|
const tokenEndpoint = raw?.tokenEndpoint ?? raw?.token_endpoint;
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tokenEndpoint,
|
||||||
|
clientIdEnv: raw?.clientIdEnv ?? raw?.client_id_env,
|
||||||
|
clientSecretEnv: raw?.clientSecretEnv ?? raw?.client_secret_env,
|
||||||
|
clientAuthMethod: raw?.clientAuthMethod ?? raw?.client_auth_method,
|
||||||
|
refreshSkewSeconds: raw?.refreshSkewSeconds ?? raw?.refresh_skew_seconds,
|
||||||
|
accessTokenEnv: raw?.accessTokenEnv ?? raw?.access_token_env,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHttpFetch(value: 'default' | 'node-http1' | undefined): 'default' | 'node-http1' | undefined {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePath(input: string | undefined): string | undefined {
|
function normalizePath(input: string | undefined): string | undefined {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@ -48,6 +48,37 @@ const RawLoggingSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('Logging configuration for the server');
|
.describe('Logging configuration for the server');
|
||||||
|
|
||||||
|
const RawHttpFetchSchema = z
|
||||||
|
.enum(['default', 'node-http1'])
|
||||||
|
.describe('HTTP fetch implementation for Streamable HTTP/SSE requests');
|
||||||
|
|
||||||
|
const RawRefreshSchema = z
|
||||||
|
.object({
|
||||||
|
tokenEndpoint: z.string().optional().describe('OAuth token endpoint used to refresh access tokens'),
|
||||||
|
token_endpoint: z.string().optional().describe('OAuth token endpoint used to refresh access tokens'),
|
||||||
|
clientIdEnv: z.string().optional().describe('Environment variable containing the OAuth client id'),
|
||||||
|
client_id_env: z.string().optional().describe('Environment variable containing the OAuth client id'),
|
||||||
|
clientSecretEnv: z.string().optional().describe('Environment variable containing the OAuth client secret'),
|
||||||
|
client_secret_env: z.string().optional().describe('Environment variable containing the OAuth client secret'),
|
||||||
|
clientAuthMethod: z.string().optional().describe('OAuth token endpoint client auth method'),
|
||||||
|
client_auth_method: z.string().optional().describe('OAuth token endpoint client auth method'),
|
||||||
|
refreshSkewSeconds: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional()
|
||||||
|
.describe('Refresh before expiry by this many seconds'),
|
||||||
|
refresh_skew_seconds: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional()
|
||||||
|
.describe('Refresh before expiry by this many seconds'),
|
||||||
|
accessTokenEnv: z.string().optional().describe('STDIO env var that receives the refreshed access token'),
|
||||||
|
access_token_env: z.string().optional().describe('STDIO env var that receives the refreshed access token'),
|
||||||
|
})
|
||||||
|
.describe('Refreshable bearer token settings');
|
||||||
|
|
||||||
export const RawEntrySchema = z
|
export const RawEntrySchema = z
|
||||||
.object({
|
.object({
|
||||||
description: z.string().optional().describe('Human-readable description of the server'),
|
description: z.string().optional().describe('Human-readable description of the server'),
|
||||||
@ -71,11 +102,11 @@ export const RawEntrySchema = z
|
|||||||
headers: z
|
headers: z
|
||||||
.record(z.string(), z.string())
|
.record(z.string(), z.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe('HTTP headers for requests. Supports $VAR and $env:VAR placeholders'),
|
.describe('HTTP headers for requests. Supports ${VAR}, ${VAR:-fallback}, and $env:VAR placeholders'),
|
||||||
env: z
|
env: z
|
||||||
.record(z.string(), z.string())
|
.record(z.string(), z.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Environment variables for stdio commands. Supports $VAR and fallback syntax'),
|
.describe('Environment variables for stdio commands. Supports ${VAR} and ${VAR:-fallback} placeholders'),
|
||||||
auth: z.string().optional().describe('Authentication method (e.g., "oauth")'),
|
auth: z.string().optional().describe('Authentication method (e.g., "oauth")'),
|
||||||
tokenCacheDir: z.string().optional().describe('Directory for caching OAuth tokens (camelCase)'),
|
tokenCacheDir: z.string().optional().describe('Directory for caching OAuth tokens (camelCase)'),
|
||||||
token_cache_dir: z.string().optional().describe('Directory for caching OAuth tokens (snake_case)'),
|
token_cache_dir: z.string().optional().describe('Directory for caching OAuth tokens (snake_case)'),
|
||||||
@ -118,6 +149,9 @@ export const RawEntrySchema = z
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Environment variable name containing the bearer token (snake_case)'),
|
.describe('Environment variable name containing the bearer token (snake_case)'),
|
||||||
|
refresh: RawRefreshSchema.optional(),
|
||||||
|
httpFetch: RawHttpFetchSchema.optional().describe('HTTP fetch implementation for Streamable HTTP/SSE requests'),
|
||||||
|
http_fetch: RawHttpFetchSchema.optional().describe('HTTP fetch implementation for Streamable HTTP/SSE requests'),
|
||||||
lifecycle: RawLifecycleSchema.optional(),
|
lifecycle: RawLifecycleSchema.optional(),
|
||||||
logging: RawLoggingSchema,
|
logging: RawLoggingSchema,
|
||||||
allowedTools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (camelCase)'),
|
allowedTools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (camelCase)'),
|
||||||
@ -141,6 +175,18 @@ export const RawEntrySchema = z
|
|||||||
export const RawConfigSchema = z
|
export const RawConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
|
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
|
imports: z
|
||||||
.array(ImportKindSchema)
|
.array(ImportKindSchema)
|
||||||
.optional()
|
.optional()
|
||||||
@ -150,6 +196,7 @@ export const RawConfigSchema = z
|
|||||||
|
|
||||||
export type RawEntry = z.infer<typeof RawEntrySchema>;
|
export type RawEntry = z.infer<typeof RawEntrySchema>;
|
||||||
export type RawConfig = z.infer<typeof RawConfigSchema>;
|
export type RawConfig = z.infer<typeof RawConfigSchema>;
|
||||||
|
export type RawRefresh = z.infer<typeof RawRefreshSchema>;
|
||||||
|
|
||||||
export interface HttpCommand {
|
export interface HttpCommand {
|
||||||
readonly kind: 'http';
|
readonly kind: 'http';
|
||||||
@ -187,6 +234,15 @@ export interface ServerLoggingOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshableBearerOptions {
|
||||||
|
readonly tokenEndpoint: string;
|
||||||
|
readonly clientIdEnv?: string;
|
||||||
|
readonly clientSecretEnv?: string;
|
||||||
|
readonly clientAuthMethod?: string;
|
||||||
|
readonly refreshSkewSeconds?: number;
|
||||||
|
readonly accessTokenEnv?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerDefinition {
|
export interface ServerDefinition {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
@ -204,6 +260,8 @@ export interface ServerDefinition {
|
|||||||
readonly oauthCommand?: {
|
readonly oauthCommand?: {
|
||||||
readonly args: string[];
|
readonly args: string[];
|
||||||
};
|
};
|
||||||
|
readonly refresh?: RefreshableBearerOptions;
|
||||||
|
readonly httpFetch?: 'default' | 'node-http1';
|
||||||
readonly source?: ServerSource;
|
readonly source?: ServerSource;
|
||||||
readonly sources?: readonly ServerSource[];
|
readonly sources?: readonly ServerSource[];
|
||||||
readonly lifecycle?: ServerLifecycle;
|
readonly lifecycle?: ServerLifecycle;
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
listConfigLayerPaths as discoverConfigLayerPaths,
|
listConfigLayerPaths as discoverConfigLayerPaths,
|
||||||
@ -17,6 +16,7 @@ import {
|
|||||||
type ServerSource,
|
type ServerSource,
|
||||||
} from './config-schema.js';
|
} from './config-schema.js';
|
||||||
import { expandHome } from './env.js';
|
import { expandHome } from './env.js';
|
||||||
|
import { writeTextFileAtomic } from './fs-json.js';
|
||||||
|
|
||||||
export { toFileUrl } from './config-imports.js';
|
export { toFileUrl } from './config-imports.js';
|
||||||
export { __configInternals } from './config-normalize.js';
|
export { __configInternals } from './config-normalize.js';
|
||||||
@ -27,6 +27,7 @@ export type {
|
|||||||
RawConfig,
|
RawConfig,
|
||||||
RawEntry,
|
RawEntry,
|
||||||
RawLifecycle,
|
RawLifecycle,
|
||||||
|
RefreshableBearerOptions,
|
||||||
ServerDefinition,
|
ServerDefinition,
|
||||||
ServerLifecycle,
|
ServerLifecycle,
|
||||||
ServerLoggingOptions,
|
ServerLoggingOptions,
|
||||||
@ -57,10 +58,16 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const [name, rawEntry] of entries) {
|
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)) {
|
if (merged.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const source: ServerSource = { kind: 'import', path: resolved, importKind };
|
|
||||||
const existing = merged.get(name);
|
const existing = merged.get(name);
|
||||||
// Keep the first-seen source as canonical while tracking all alternates
|
// Keep the first-seen source as canonical while tracking all alternates
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -69,7 +76,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
}
|
}
|
||||||
merged.set(name, {
|
merged.set(name, {
|
||||||
raw: rawEntry,
|
raw: rawEntry,
|
||||||
baseDir: path.dirname(resolved),
|
baseDir,
|
||||||
source,
|
source,
|
||||||
sources: [source],
|
sources: [source],
|
||||||
});
|
});
|
||||||
@ -98,12 +105,35 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
|
|
||||||
const servers: ServerDefinition[] = [];
|
const servers: ServerDefinition[] = [];
|
||||||
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
|
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;
|
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(
|
export async function loadRawConfig(
|
||||||
options: LoadConfigOptions = {}
|
options: LoadConfigOptions = {}
|
||||||
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
|
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
|
||||||
@ -121,9 +151,8 @@ export async function listConfigLayerPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function writeRawConfig(targetPath: string, config: RawConfig): Promise<void> {
|
export async function writeRawConfig(targetPath: string, config: RawConfig): Promise<void> {
|
||||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
const serialized = `${JSON.stringify(config, null, 2)}\n`;
|
const serialized = `${JSON.stringify(config, null, 2)}\n`;
|
||||||
await fs.writeFile(targetPath, serialized, 'utf8');
|
await writeTextFileAtomic(targetPath, serialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfigPath(
|
export function resolveConfigPath(
|
||||||
|
|||||||
@ -153,6 +153,16 @@ function convertExternalEntry(value: Record<string, unknown>): RawEntry | null {
|
|||||||
result.oauthTokenEndpointAuthMethod = oauthTokenEndpointAuthMethod;
|
result.oauthTokenEndpointAuthMethod = oauthTokenEndpointAuthMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const httpFetch = asString(value.httpFetch ?? value.http_fetch);
|
||||||
|
if (httpFetch) {
|
||||||
|
result.httpFetch = httpFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = asRefresh(value.refresh);
|
||||||
|
if (refresh) {
|
||||||
|
result.refresh = refresh;
|
||||||
|
}
|
||||||
|
|
||||||
const url = asString(value.baseUrl ?? value.base_url ?? value.url ?? value.serverUrl ?? value.server_url);
|
const url = asString(value.baseUrl ?? value.base_url ?? value.url ?? value.serverUrl ?? value.server_url);
|
||||||
if (url) {
|
if (url) {
|
||||||
result.baseUrl = url;
|
result.baseUrl = url;
|
||||||
@ -201,6 +211,36 @@ function buildExternalHeaders(record: Record<string, unknown>): Record<string, s
|
|||||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asRefresh(value: unknown): RawEntry['refresh'] | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
copyString(record, result, 'tokenEndpoint', 'token_endpoint');
|
||||||
|
copyString(record, result, 'clientIdEnv', 'client_id_env');
|
||||||
|
copyString(record, result, 'clientSecretEnv', 'client_secret_env');
|
||||||
|
copyString(record, result, 'clientAuthMethod', 'client_auth_method');
|
||||||
|
copyString(record, result, 'accessTokenEnv', 'access_token_env');
|
||||||
|
const refreshSkewSeconds = record.refreshSkewSeconds ?? record.refresh_skew_seconds;
|
||||||
|
if (typeof refreshSkewSeconds === 'number' && Number.isInteger(refreshSkewSeconds) && refreshSkewSeconds >= 0) {
|
||||||
|
result.refreshSkewSeconds = refreshSkewSeconds;
|
||||||
|
}
|
||||||
|
return Object.keys(result).length > 0 ? (result as RawEntry['refresh']) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyString(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
camel: string,
|
||||||
|
snake: string
|
||||||
|
): void {
|
||||||
|
const value = asString(source[camel] ?? source[snake]);
|
||||||
|
if (value) {
|
||||||
|
target[camel] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractClaudeProjectEntries(raw: Record<string, unknown>, projectRoot: string): Map<string, RawEntry> {
|
function extractClaudeProjectEntries(raw: Record<string, unknown>, projectRoot: string): Map<string, RawEntry> {
|
||||||
const map = new Map<string, RawEntry>();
|
const map = new Map<string, RawEntry>();
|
||||||
if (!isRecord(raw.projects)) {
|
if (!isRecord(raw.projects)) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { listConfigLayerPaths } from '../config/path-discovery.js';
|
import { listConfigLayerPaths } from '../config/path-discovery.js';
|
||||||
|
import { withFileLock } from '../fs-json.js';
|
||||||
import { getDaemonMetadataPath, getDaemonSocketPath } from './paths.js';
|
import { getDaemonMetadataPath, getDaemonSocketPath } from './paths.js';
|
||||||
import type {
|
import type {
|
||||||
CallToolParams,
|
CallToolParams,
|
||||||
@ -23,6 +24,7 @@ export interface DaemonClientOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DAEMON_TIMEOUT_MS = 30_000;
|
const DEFAULT_DAEMON_TIMEOUT_MS = 30_000;
|
||||||
|
const MIN_DAEMON_STATUS_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
export interface DaemonPaths {
|
export interface DaemonPaths {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
@ -83,14 +85,7 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async status(): Promise<StatusResult | null> {
|
async status(): Promise<StatusResult | null> {
|
||||||
try {
|
return await this.readVerifiedStatus();
|
||||||
return (await this.sendRequest<StatusResult>('status', {})) as StatusResult;
|
|
||||||
} catch (error) {
|
|
||||||
if (isTransportError(error)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
@ -105,7 +100,7 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async invoke<T = unknown>(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise<T> {
|
private async invoke<T = unknown>(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise<T> {
|
||||||
await this.ensureDaemon();
|
await this.ensureDaemon(timeoutMs);
|
||||||
try {
|
try {
|
||||||
return (await this.sendRequest<T>(method, params, timeoutMs)) as T;
|
return (await this.sendRequest<T>(method, params, timeoutMs)) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,47 +112,87 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureDaemon(): Promise<void> {
|
private async ensureDaemon(timeoutMs?: number): Promise<void> {
|
||||||
const configState = await this.checkConfigState();
|
const statusTimeoutMs = resolveDaemonStatusTimeout(timeoutMs);
|
||||||
|
const metadata = await readDaemonMetadata(this.metadataPath);
|
||||||
|
const configState = await this.checkConfigState(metadata);
|
||||||
if (configState === 'stale') {
|
if (configState === 'stale') {
|
||||||
await this.stop().catch(() => {});
|
await this.restartDaemon({ reason: 'stale-config', expectedPid: metadata?.pid });
|
||||||
await this.restartDaemon();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (configState === 'fresh') {
|
if (configState === 'fresh') {
|
||||||
return;
|
if (await this.isResponsive(statusTimeoutMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.startDaemon();
|
await this.startDaemon({ preflightTimeoutMs: statusTimeoutMs });
|
||||||
await this.waitForReady();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restartDaemon(): Promise<void> {
|
private async restartDaemon(options: { reason?: 'stale-config'; expectedPid?: number } = {}): Promise<void> {
|
||||||
await this.startDaemon();
|
await this.startingWithLock(async () => {
|
||||||
await this.waitForReady();
|
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) {
|
if (this.startingPromise) {
|
||||||
await this.startingPromise;
|
await this.startingPromise;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.startingPromise = Promise.resolve()
|
this.startingPromise = withFileLock(this.metadataPath, async () => {
|
||||||
.then(async () => {
|
await task();
|
||||||
const { launchDaemonDetached } = await import('./launch.js');
|
}).finally(() => {
|
||||||
launchDaemonDetached({
|
this.startingPromise = null;
|
||||||
configPath: this.options.configPath,
|
});
|
||||||
configExplicit: this.options.configExplicit,
|
|
||||||
rootDir: this.options.rootDir,
|
|
||||||
metadataPath: this.metadataPath,
|
|
||||||
socketPath: this.socketPath,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.startingPromise = null;
|
|
||||||
});
|
|
||||||
await this.startingPromise;
|
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> {
|
private async waitForReady(): Promise<void> {
|
||||||
const deadline = Date.now() + 10_000;
|
const deadline = Date.now() + 10_000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@ -169,20 +204,31 @@ export class DaemonClient {
|
|||||||
throw new Error('Timeout while waiting for MCPorter daemon to start.');
|
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 {
|
try {
|
||||||
await this.sendRequest('status', {});
|
const status = (await this.sendRequest<StatusResult>('status', {}, timeoutMs)) as StatusResult;
|
||||||
return true;
|
if (status.pid !== metadata.pid || status.socketPath !== metadata.socketPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isTransportError(error)) {
|
if (isTransportError(error)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkConfigState(): Promise<DaemonConfigState> {
|
private async checkConfigState(metadata?: DaemonMetadata | null): Promise<DaemonConfigState> {
|
||||||
const metadata = await readDaemonMetadata(this.metadataPath);
|
metadata ??= await readDaemonMetadata(this.metadataPath);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
}
|
}
|
||||||
@ -290,6 +336,18 @@ function isTransportError(error: unknown): boolean {
|
|||||||
return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ETIMEDOUT' || code === 'ECONNRESET';
|
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 {
|
function resolveDaemonTimeout(override?: number): number {
|
||||||
if (typeof override === 'number' && Number.isFinite(override) && override > 0) {
|
if (typeof override === 'number' && Number.isFinite(override) && override > 0) {
|
||||||
return override;
|
return override;
|
||||||
@ -305,6 +363,13 @@ function resolveDaemonTimeout(override?: number): number {
|
|||||||
return parsed;
|
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> {
|
async function statConfigMtime(configPath: string): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(configPath);
|
const stats = await fs.stat(configPath);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
import type { LoadConfigOptions } from '../config.js';
|
import type { LoadConfigOptions } from '../config.js';
|
||||||
import { listConfigLayerPaths } from '../config.js';
|
import { listConfigLayerPaths } from '../config.js';
|
||||||
|
|
||||||
@ -19,5 +20,8 @@ export async function collectConfigLayers(
|
|||||||
for (const layerPath of layerPaths) {
|
for (const layerPath of layerPaths) {
|
||||||
entries.push({ path: layerPath, mtimeMs: await statConfigMtime(layerPath) });
|
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;
|
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,10 +1,13 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { ServerDefinition } from '../config.js';
|
import { loadDaemonConfig, type ServerDefinition } from '../config.js';
|
||||||
|
import { readJsonFile, withFileLock, writeJsonFile } from '../fs-json.js';
|
||||||
import { isKeepAliveServer } from '../lifecycle.js';
|
import { isKeepAliveServer } from '../lifecycle.js';
|
||||||
import { createRuntime, type Runtime } from '../runtime.js';
|
import { createRuntime, type Runtime } from '../runtime.js';
|
||||||
import { collectConfigLayers, statConfigMtime } from './config-layers.js';
|
import { collectConfigLayers, statConfigMtime } from './config-layers.js';
|
||||||
|
import { hashDaemonDefinitions } from './definition-hash.js';
|
||||||
import {
|
import {
|
||||||
createLogContext,
|
createLogContext,
|
||||||
disposeLogContext,
|
disposeLogContext,
|
||||||
@ -25,9 +28,11 @@ import type {
|
|||||||
} from './protocol.js';
|
} from './protocol.js';
|
||||||
import {
|
import {
|
||||||
buildErrorResponse,
|
buildErrorResponse,
|
||||||
|
daemonIdleWatcherInterval,
|
||||||
ensureManaged,
|
ensureManaged,
|
||||||
evictIdleServers,
|
evictIdleServers,
|
||||||
markActivity,
|
markActivity,
|
||||||
|
shouldShutdownDaemonForIdle,
|
||||||
type ServerActivity,
|
type ServerActivity,
|
||||||
} from './request-utils.js';
|
} from './request-utils.js';
|
||||||
|
|
||||||
@ -47,11 +52,16 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
configPath: options.configExplicit ? options.configPath : undefined,
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
});
|
});
|
||||||
|
const daemonConfig = await loadDaemonConfig({
|
||||||
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
|
rootDir: options.rootDir,
|
||||||
|
});
|
||||||
const runtime = await createRuntime({
|
const runtime = await createRuntime({
|
||||||
configPath: options.configExplicit ? options.configPath : undefined,
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
});
|
});
|
||||||
const keepAliveDefinitions = runtime.getDefinitions().filter(isKeepAliveServer);
|
const keepAliveDefinitions = runtime.getDefinitions().filter(isKeepAliveServer);
|
||||||
|
const definitionHash = hashDaemonDefinitions(keepAliveDefinitions);
|
||||||
if (keepAliveDefinitions.length === 0) {
|
if (keepAliveDefinitions.length === 0) {
|
||||||
throw new Error('No MCP servers require keep-alive; daemon will not start.');
|
throw new Error('No MCP servers require keep-alive; daemon will not start.');
|
||||||
}
|
}
|
||||||
@ -76,7 +86,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
logPath: options.logPath,
|
logPath: options.logPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
await prepareSocket(options.socketPath);
|
|
||||||
await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
|
await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
|
||||||
const configMtimeMs = await statConfigMtime(options.configPath);
|
const configMtimeMs = await statConfigMtime(options.configPath);
|
||||||
|
|
||||||
@ -85,9 +94,37 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
activity.set(definition.name, { connected: false });
|
activity.set(definition.name, { connected: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const idleWatcher = setInterval(() => {
|
let shuttingDown = false;
|
||||||
void evictIdleServers(runtime, managedServers, activity);
|
let idleWatcher: NodeJS.Timeout | undefined;
|
||||||
}, 30_000);
|
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();
|
idleWatcher.unref();
|
||||||
|
|
||||||
logEvent(logContext, 'Daemon host started.');
|
logEvent(logContext, 'Daemon host started.');
|
||||||
@ -114,6 +151,8 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
|
lastDaemonActivityAt = Date.now();
|
||||||
|
activeDaemonRequests += 1;
|
||||||
void handleSocketRequest(
|
void handleSocketRequest(
|
||||||
trimmed,
|
trimmed,
|
||||||
socket,
|
socket,
|
||||||
@ -127,11 +166,15 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
startedAt,
|
startedAt,
|
||||||
logPath: options.logPath ?? null,
|
logPath: options.logPath ?? null,
|
||||||
configMtimeMs,
|
configMtimeMs,
|
||||||
|
definitionHash,
|
||||||
},
|
},
|
||||||
logContext,
|
logContext,
|
||||||
shutdown,
|
shutdown,
|
||||||
parsedRequest
|
parsedRequest
|
||||||
);
|
).finally(() => {
|
||||||
|
activeDaemonRequests -= 1;
|
||||||
|
lastDaemonActivityAt = Date.now();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
buffer += chunk;
|
buffer += chunk;
|
||||||
@ -148,52 +191,252 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
let claimed = false;
|
||||||
server.once('error', reject);
|
await withFileLock(`${options.metadataPath}.bind`, async () => {
|
||||||
server.listen(options.socketPath, () => {
|
const live = await probeLiveDaemon(options.socketPath);
|
||||||
server.off('error', reject);
|
if (live) {
|
||||||
resolve();
|
if (daemonConfigMatches(live, configLayers, options.configPath, configMtimeMs, definitionHash)) {
|
||||||
|
if (!(await metadataMatches(options.metadataPath, live))) {
|
||||||
|
await writeJsonFile(options.metadataPath, metadataFromStatus(live, configLayers));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await stopLiveDaemon(options.socketPath, live.pid);
|
||||||
|
}
|
||||||
|
await prepareSocket(options.socketPath);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(options.socketPath, () => {
|
||||||
|
server.off('error', reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
await writeJsonFile(options.metadataPath, {
|
||||||
|
pid: process.pid,
|
||||||
|
socketPath: options.socketPath,
|
||||||
|
configPath: options.configPath,
|
||||||
|
configLayers,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
logPath: options.logPath ?? null,
|
||||||
|
configMtimeMs,
|
||||||
|
definitionHash,
|
||||||
|
});
|
||||||
|
claimed = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await fs.writeFile(
|
if (!claimed) {
|
||||||
options.metadataPath,
|
logEvent(logContext, 'Daemon already running for this config; exiting without rebinding.');
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
pid: process.pid,
|
|
||||||
socketPath: options.socketPath,
|
|
||||||
configPath: options.configPath,
|
|
||||||
configLayers,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
logPath: options.logPath ?? null,
|
|
||||||
configMtimeMs,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
let shuttingDown = false;
|
|
||||||
const shutdown = async (): Promise<void> => {
|
|
||||||
if (shuttingDown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shuttingDown = true;
|
|
||||||
logEvent(logContext, 'Shutting down daemon host.');
|
|
||||||
clearInterval(idleWatcher);
|
|
||||||
server.close();
|
server.close();
|
||||||
await runtime.close().catch(() => {});
|
await runtime.close().catch(() => {});
|
||||||
await disposeLogContext(logContext).catch(() => {});
|
await disposeLogContext(logContext).catch(() => {});
|
||||||
await cleanupArtifacts(options);
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
}
|
||||||
|
|
||||||
process.once('SIGINT', shutdown);
|
process.once('SIGINT', shutdown);
|
||||||
process.once('SIGTERM', shutdown);
|
process.once('SIGTERM', shutdown);
|
||||||
process.once('SIGQUIT', 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> {
|
async function prepareSocket(socketPath: string): Promise<void> {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return;
|
return;
|
||||||
@ -209,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupArtifacts(options: DaemonHostOptions): 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') {
|
if (process.platform !== 'win32') {
|
||||||
try {
|
await fs.unlink(paths.socketPath).catch(() => {});
|
||||||
await fs.unlink(options.socketPath);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fs.unlink(options.metadataPath);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
await fs.unlink(paths.metadataPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSocketRequest(
|
async function handleSocketRequest(
|
||||||
@ -236,6 +485,7 @@ async function handleSocketRequest(
|
|||||||
socketPath: string;
|
socketPath: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
|
definitionHash?: string;
|
||||||
},
|
},
|
||||||
logContext: LogContext,
|
logContext: LogContext,
|
||||||
shutdown: () => Promise<void>,
|
shutdown: () => Promise<void>,
|
||||||
@ -259,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(
|
async function processRequest(
|
||||||
rawPayload: string,
|
rawPayload: string,
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
@ -271,6 +528,7 @@ async function processRequest(
|
|||||||
socketPath: string;
|
socketPath: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
|
definitionHash?: string;
|
||||||
},
|
},
|
||||||
logContext: LogContext,
|
logContext: LogContext,
|
||||||
preParsedRequest?: DaemonRequest
|
preParsedRequest?: DaemonRequest
|
||||||
@ -309,6 +567,7 @@ async function processRequest(
|
|||||||
const result = await runtime.callTool(params.server, params.tool, {
|
const result = await runtime.callTool(params.server, params.tool, {
|
||||||
args: params.args ?? {},
|
args: params.args ?? {},
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
|
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||||
});
|
});
|
||||||
markActivity(params.server, activity);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
@ -326,6 +585,7 @@ async function processRequest(
|
|||||||
case 'listTools': {
|
case 'listTools': {
|
||||||
const params = request.params as ListToolsParams;
|
const params = request.params as ListToolsParams;
|
||||||
ensureManaged(params.server, managedServers);
|
ensureManaged(params.server, managedServers);
|
||||||
|
const definition = managedServers.get(params.server)!;
|
||||||
const loggable = shouldLogServer(logContext, params.server);
|
const loggable = shouldLogServer(logContext, params.server);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
logEvent(logContext, `listTools start server=${params.server}`);
|
logEvent(logContext, `listTools start server=${params.server}`);
|
||||||
@ -333,7 +593,9 @@ async function processRequest(
|
|||||||
try {
|
try {
|
||||||
const result = await runtime.listTools(params.server, {
|
const result = await runtime.listTools(params.server, {
|
||||||
includeSchema: params.includeSchema,
|
includeSchema: params.includeSchema,
|
||||||
autoAuthorize: params.autoAuthorize,
|
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
|
||||||
|
allowCachedAuth: params.allowCachedAuth ?? true,
|
||||||
|
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||||
});
|
});
|
||||||
markActivity(params.server, activity);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
@ -356,7 +618,11 @@ async function processRequest(
|
|||||||
logEvent(logContext, `listResources start server=${params.server}`);
|
logEvent(logContext, `listResources start server=${params.server}`);
|
||||||
}
|
}
|
||||||
try {
|
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);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
logEvent(logContext, `listResources success server=${params.server}`);
|
logEvent(logContext, `listResources success server=${params.server}`);
|
||||||
@ -378,7 +644,10 @@ async function processRequest(
|
|||||||
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
|
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
|
||||||
}
|
}
|
||||||
try {
|
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);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
logEvent(logContext, `readResource success server=${params.server}`);
|
logEvent(logContext, `readResource success server=${params.server}`);
|
||||||
@ -424,6 +693,7 @@ async function processRequest(
|
|||||||
configPath: metadata.configPath,
|
configPath: metadata.configPath,
|
||||||
configLayers: metadata.configLayers,
|
configLayers: metadata.configLayers,
|
||||||
configMtimeMs: metadata.configMtimeMs,
|
configMtimeMs: metadata.configMtimeMs,
|
||||||
|
definitionHash: metadata.definitionHash,
|
||||||
socketPath: metadata.socketPath,
|
socketPath: metadata.socketPath,
|
||||||
logPath: metadata.logPath ?? undefined,
|
logPath: metadata.logPath ?? undefined,
|
||||||
servers: Array.from(managedServers.values()).map((def) => {
|
servers: Array.from(managedServers.values()).map((def) => {
|
||||||
@ -458,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(
|
export async function __testProcessRequest(
|
||||||
rawPayload: string,
|
rawPayload: string,
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
@ -470,6 +750,7 @@ export async function __testProcessRequest(
|
|||||||
socketPath: string;
|
socketPath: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
|
definitionHash?: string;
|
||||||
},
|
},
|
||||||
logContext: LogContext,
|
logContext: LogContext,
|
||||||
preParsedRequest?: DaemonRequest
|
preParsedRequest?: DaemonRequest
|
||||||
|
|||||||
@ -28,22 +28,29 @@ export interface CallToolParams {
|
|||||||
readonly tool: string;
|
readonly tool: string;
|
||||||
readonly args?: Record<string, unknown>;
|
readonly args?: Record<string, unknown>;
|
||||||
readonly timeoutMs?: number;
|
readonly timeoutMs?: number;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListToolsParams {
|
export interface ListToolsParams {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly includeSchema?: boolean;
|
readonly includeSchema?: boolean;
|
||||||
readonly autoAuthorize?: boolean;
|
readonly autoAuthorize?: boolean;
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListResourcesParams {
|
export interface ListResourcesParams {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly params?: Record<string, unknown>;
|
readonly params?: Record<string, unknown>;
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadResourceParams {
|
export interface ReadResourceParams {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly uri: string;
|
readonly uri: string;
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloseServerParams {
|
export interface CloseServerParams {
|
||||||
@ -59,6 +66,7 @@ export interface StatusResult {
|
|||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly mtimeMs: number | null;
|
readonly mtimeMs: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
readonly definitionHash?: string;
|
||||||
readonly socketPath: string;
|
readonly socketPath: string;
|
||||||
readonly logPath?: string;
|
readonly logPath?: string;
|
||||||
readonly servers: Array<{
|
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 {
|
export function buildErrorResponse(id: string, code: string, error?: unknown): DaemonResponse {
|
||||||
let message = code;
|
let message = code;
|
||||||
if (error instanceof Error) {
|
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 { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ServerDefinition } from '../config.js';
|
import type { ServerDefinition } from '../config.js';
|
||||||
import { isKeepAliveServer } from '../lifecycle.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';
|
import type { DaemonClient } from './client.js';
|
||||||
|
|
||||||
interface KeepAliveRuntimeOptions {
|
interface KeepAliveRuntimeOptions {
|
||||||
@ -52,12 +58,17 @@ class KeepAliveRuntime implements Runtime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listTools(server: string, options?: ListToolsOptions): Promise<Awaited<ReturnType<Runtime['listTools']>>> {
|
async listTools(server: string, options?: ListToolsOptions): Promise<Awaited<ReturnType<Runtime['listTools']>>> {
|
||||||
|
if (options?.oauthSessionOptions) {
|
||||||
|
return this.base.listTools(server, options);
|
||||||
|
}
|
||||||
if (this.shouldUseDaemon(server)) {
|
if (this.shouldUseDaemon(server)) {
|
||||||
return (await this.invokeWithRestart(server, 'listTools', () =>
|
return (await this.invokeWithRestart(server, 'listTools', () =>
|
||||||
this.daemon.listTools({
|
this.daemon.listTools({
|
||||||
server,
|
server,
|
||||||
includeSchema: options?.includeSchema,
|
includeSchema: options?.includeSchema,
|
||||||
autoAuthorize: options?.autoAuthorize,
|
autoAuthorize: options?.autoAuthorize,
|
||||||
|
allowCachedAuth: options?.allowCachedAuth ?? true,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
})
|
})
|
||||||
)) as Awaited<ReturnType<Runtime['listTools']>>;
|
)) as Awaited<ReturnType<Runtime['listTools']>>;
|
||||||
}
|
}
|
||||||
@ -72,30 +83,45 @@ class KeepAliveRuntime implements Runtime {
|
|||||||
tool: toolName,
|
tool: toolName,
|
||||||
args: options?.args,
|
args: options?.args,
|
||||||
timeoutMs: options?.timeoutMs,
|
timeoutMs: options?.timeoutMs,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.base.callTool(server, toolName, options);
|
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)) {
|
if (this.shouldUseDaemon(server)) {
|
||||||
return this.invokeWithRestart(server, 'listResources', () =>
|
return this.invokeWithRestart(server, 'listResources', () =>
|
||||||
this.daemon.listResources({ server, params: options ?? {} })
|
this.daemon.listResources({ server, params, allowCachedAuth, disableOAuth })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.base.listResources(server, options);
|
return this.base.listResources(server, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readResource(server: string, uri: string): Promise<unknown> {
|
async readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown> {
|
||||||
if (this.shouldUseDaemon(server)) {
|
if (options?.oauthSessionOptions) {
|
||||||
return this.invokeWithRestart(server, 'readResource', () => this.daemon.readResource({ server, uri }));
|
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']>>> {
|
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||||
return this.base.connect(server);
|
return this.base.connect(server, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(server?: string): Promise<void> {
|
async close(server?: string): Promise<void> {
|
||||||
|
|||||||
10
src/env.ts
10
src/env.ts
@ -2,7 +2,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const ENV_DEFAULT_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-|:|-)?([^}]*)\}$/;
|
const ENV_DEFAULT_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-|:|-)?([^}]*)\}$/;
|
||||||
const ENV_INTERPOLATION_PATTERN = /\\?\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
const ENV_INTERPOLATION_PATTERN = /\\?\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}/g;
|
||||||
const ENV_DIRECT_PREFIX = '$env:';
|
const ENV_DIRECT_PREFIX = '$env:';
|
||||||
|
|
||||||
// expandHome replaces a leading '~' with the current user's home directory.
|
// expandHome replaces a leading '~' with the current user's home directory.
|
||||||
@ -59,8 +59,14 @@ export function resolveEnvPlaceholders(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const missing = new Set<string>();
|
const missing = new Set<string>();
|
||||||
const replaced = value.replace(ENV_INTERPOLATION_PATTERN, (placeholder, envName: string) => {
|
const replaced = value.replace(ENV_INTERPOLATION_PATTERN, (placeholder, envName: string, fallback?: string) => {
|
||||||
const envValue = process.env[envName];
|
const envValue = process.env[envName];
|
||||||
|
if (envValue !== undefined && envValue !== '') {
|
||||||
|
return envValue;
|
||||||
|
}
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
if (envValue === undefined) {
|
if (envValue === undefined) {
|
||||||
missing.add(envName);
|
missing.add(envName);
|
||||||
return placeholder;
|
return placeholder;
|
||||||
|
|||||||
273
src/fs-json.ts
273
src/fs-json.ts
@ -1,6 +1,15 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
||||||
|
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.
|
// 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> {
|
export async function readJsonFile<T = unknown>(filePath: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
@ -14,8 +23,268 @@ export async function readJsonFile<T = unknown>(filePath: string): Promise<T | u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeTextFileAtomic writes a file via same-directory temp file and rename.
|
||||||
|
export async function writeTextFileAtomic(filePath: string, data: string): Promise<void> {
|
||||||
|
const target = await resolveAtomicWriteTarget(filePath);
|
||||||
|
await fs.mkdir(path.dirname(target.path), { recursive: true });
|
||||||
|
const tempPath = path.join(
|
||||||
|
path.dirname(target.path),
|
||||||
|
`.${path.basename(target.path)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (target.mode !== undefined) {
|
||||||
|
await fs.access(target.path, constants.W_OK);
|
||||||
|
}
|
||||||
|
await fs.writeFile(tempPath, data, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
mode: target.mode ?? DEFAULT_ATOMIC_FILE_MODE,
|
||||||
|
});
|
||||||
|
if (target.mode !== undefined) {
|
||||||
|
await fs.chmod(tempPath, target.mode);
|
||||||
|
}
|
||||||
|
await fs.rename(tempPath, target.path);
|
||||||
|
} catch (error) {
|
||||||
|
await fs.unlink(tempPath).catch(() => {});
|
||||||
|
if (target.mode !== undefined && isPermissionError(error)) {
|
||||||
|
await fs.writeFile(filePath, data, 'utf8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// writeJsonFile writes a JSON object to disk, ensuring parent directories are created first.
|
// writeJsonFile writes a JSON object to disk, ensuring parent directories are created first.
|
||||||
export async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
export async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await writeTextFileAtomic(filePath, JSON.stringify(data, null, 2));
|
||||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
}
|
||||||
|
|
||||||
|
export async function withFileLock<T>(
|
||||||
|
filePath: string,
|
||||||
|
task: () => Promise<T>,
|
||||||
|
options: { timeoutMs?: number } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const lockTargetPath = await resolvePathFollowingSymlinks(filePath);
|
||||||
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
return withLocalLock(lockTargetPath, timeoutMs, async () => {
|
||||||
|
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
|
||||||
|
let lockPath = `${lockTargetPath}.lock`;
|
||||||
|
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
|
||||||
|
let acquired = false;
|
||||||
|
|
||||||
|
while (!acquired) {
|
||||||
|
try {
|
||||||
|
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
acquired = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
|
||||||
|
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
|
||||||
|
lockPath = fallbackLockPath;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (await removeRecoverableLock(lockPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Date.now() - startedAt > timeoutMs) {
|
||||||
|
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
|
||||||
|
}
|
||||||
|
await sleep(LOCK_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
return code === 'EACCES' || code === 'EPERM';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
const targetPath = await resolvePathFollowingSymlinks(filePath);
|
||||||
|
return { path: targetPath, mode: await readMode(targetPath) };
|
||||||
|
}
|
||||||
|
return { path: filePath, mode: stats.mode & 0o777 };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return { path: filePath };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePathFollowingSymlinks(filePath: string): Promise<string> {
|
||||||
|
let currentPath = await canonicalizeParentDirectory(filePath);
|
||||||
|
for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth += 1) {
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await fs.lstat(currentPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return await canonicalizeParentDirectory(currentPath);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!stats.isSymbolicLink()) {
|
||||||
|
return currentPath;
|
||||||
|
}
|
||||||
|
const link = await fs.readlink(currentPath);
|
||||||
|
currentPath = await canonicalizeParentDirectory(
|
||||||
|
path.isAbsolute(link) ? link : path.resolve(path.dirname(currentPath), link)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Too many symbolic links while resolving ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canonicalizeParentDirectory(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return path.join(await fs.realpath(path.dirname(filePath)), path.basename(filePath));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMode(filePath: string): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
return stats.mode & 0o777;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRecoverableLock(lockPath: string): Promise<boolean> {
|
||||||
|
const breakerPath = `${lockPath}.break`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(breakerPath, `${process.pid}\n${new Date().toISOString()}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(await isLockRecoverable(breakerPath))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await fs.unlink(breakerPath).catch(() => {});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await isLockRecoverable(lockPath))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await fs.unlink(lockPath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(breakerPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLockRecoverable(lockPath: string): Promise<boolean> {
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = await fs.readFile(lockPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||||
|
}
|
||||||
|
if (contents.length === 0) {
|
||||||
|
return await isMalformedLockStale(lockPath);
|
||||||
|
}
|
||||||
|
const pid = Number.parseInt(contents.split(/\r?\n/, 1)[0] ?? '', 10);
|
||||||
|
if (Number.isInteger(pid) && pid > 0) {
|
||||||
|
return !isProcessRunning(pid);
|
||||||
|
}
|
||||||
|
return await isMalformedLockStale(lockPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isMalformedLockStale(lockPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(lockPath);
|
||||||
|
return Date.now() - stats.mtimeMs > MALFORMED_LOCK_STALE_MS;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
bundleOutput,
|
bundleOutput,
|
||||||
@ -10,8 +11,10 @@ import { ensureInvocationDefaults, fetchTools, resolveServerDefinition } from '.
|
|||||||
import { resolveRuntimeKind } from './cli/generate/runtime.js';
|
import { resolveRuntimeKind } from './cli/generate/runtime.js';
|
||||||
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
|
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
|
||||||
import type { ToolMetadata } from './cli/generate/tools.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 { 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';
|
import type { ServerToolInfo } from './runtime.js';
|
||||||
|
|
||||||
export interface GenerateCliOptions {
|
export interface GenerateCliOptions {
|
||||||
@ -29,6 +32,8 @@ export interface GenerateCliOptions {
|
|||||||
readonly excludeTools?: string[];
|
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.
|
// generateCli produces a standalone CLI (and optional bundle/binary) for a given MCP server.
|
||||||
export async function generateCli(
|
export async function generateCli(
|
||||||
options: GenerateCliOptions
|
options: GenerateCliOptions
|
||||||
@ -55,7 +60,9 @@ export async function generateCli(
|
|||||||
baseDefinition.description || !derivedDescription
|
baseDefinition.description || !derivedDescription
|
||||||
? baseDefinition
|
? baseDefinition
|
||||||
: { ...baseDefinition, description: derivedDescription };
|
: { ...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 generator = await readPackageMetadata();
|
||||||
const baseInvocation = ensureInvocationDefaults(
|
const baseInvocation = ensureInvocationDefaults(
|
||||||
{
|
{
|
||||||
@ -72,34 +79,31 @@ export async function generateCli(
|
|||||||
includeTools: options.includeTools,
|
includeTools: options.includeTools,
|
||||||
excludeTools: options.excludeTools,
|
excludeTools: options.excludeTools,
|
||||||
},
|
},
|
||||||
definition
|
embeddedDefinition
|
||||||
);
|
);
|
||||||
const embeddedMetadata: CliArtifactMetadata = {
|
const embeddedMetadata: CliArtifactMetadata = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: REPRODUCIBLE_GENERATED_AT,
|
||||||
generator,
|
generator,
|
||||||
server: {
|
server: {
|
||||||
name,
|
name,
|
||||||
source: definition.source,
|
definition: serializedDefinition,
|
||||||
definition: serializeDefinition(definition),
|
|
||||||
},
|
},
|
||||||
artifact: {
|
artifact: {
|
||||||
path: '',
|
path: '',
|
||||||
kind: 'template',
|
kind: 'template',
|
||||||
},
|
},
|
||||||
invocation: baseInvocation,
|
invocation: buildEmbeddedInvocation(baseInvocation, serializedDefinition),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldBundle = Boolean(options.bundle ?? options.compile);
|
||||||
let templateTmpDir: string | undefined;
|
let templateTmpDir: string | undefined;
|
||||||
let templateOutputPath = options.outputPath;
|
let templateOutputPath = options.outputPath;
|
||||||
if (!templateOutputPath && options.compile) {
|
if (!templateOutputPath && shouldBundle) {
|
||||||
const tmpPrefix = path.join(process.cwd(), 'tmp', 'mcporter-cli-');
|
templateTmpDir = resolveImplicitTemplateDir(name, serializedDefinition);
|
||||||
await fs.mkdir(path.dirname(tmpPrefix), { recursive: true });
|
await fs.mkdir(templateTmpDir, { recursive: true });
|
||||||
templateTmpDir = await fs.mkdtemp(tmpPrefix);
|
templateOutputPath = path.join(templateTmpDir, `${sanitizePathSegment(name) || 'server'}.ts`);
|
||||||
templateOutputPath = path.join(templateTmpDir, `${name}.ts`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldBundle = Boolean(options.bundle ?? options.compile);
|
|
||||||
const templateSourcePath = path.resolve(templateOutputPath ?? path.resolve(process.cwd(), `${name}.ts`));
|
const templateSourcePath = path.resolve(templateOutputPath ?? path.resolve(process.cwd(), `${name}.ts`));
|
||||||
let resolvedBundleTarget: string | undefined;
|
let resolvedBundleTarget: string | undefined;
|
||||||
let resolvedCompileTarget: string | undefined;
|
let resolvedCompileTarget: string | undefined;
|
||||||
@ -122,7 +126,7 @@ export async function generateCli(
|
|||||||
runtimeScriptPath,
|
runtimeScriptPath,
|
||||||
runtimeKind,
|
runtimeKind,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
definition,
|
definition: embeddedDefinition,
|
||||||
serverName: name,
|
serverName: name,
|
||||||
tools: toolMetadata,
|
tools: toolMetadata,
|
||||||
generator,
|
generator,
|
||||||
@ -157,13 +161,49 @@ export async function generateCli(
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (templateTmpDir) {
|
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 };
|
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[] {
|
function applyToolFilters(tools: ServerToolInfo[], includeTools?: string[], excludeTools?: string[]): ServerToolInfo[] {
|
||||||
if (includeTools && excludeTools) {
|
if (includeTools && excludeTools) {
|
||||||
throw new Error('Internal error: both includeTools and excludeTools provided to generateCli.');
|
throw new Error('Internal error: both includeTools and excludeTools provided to generateCli.');
|
||||||
|
|||||||
@ -80,6 +80,8 @@ function serializeRawEntry(server: ServerDefinition): RawEntry {
|
|||||||
...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}),
|
...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}),
|
||||||
...(server.oauthScope ? { oauthScope: server.oauthScope } : {}),
|
...(server.oauthScope ? { oauthScope: server.oauthScope } : {}),
|
||||||
...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}),
|
...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}),
|
||||||
|
...(server.refresh ? { refresh: server.refresh } : {}),
|
||||||
|
...(server.httpFetch ? { httpFetch: server.httpFetch } : {}),
|
||||||
...(server.lifecycle ? { lifecycle: serializeLifecycle(server.lifecycle) } : {}),
|
...(server.lifecycle ? { lifecycle: serializeLifecycle(server.lifecycle) } : {}),
|
||||||
...(server.logging ? { logging: server.logging } : {}),
|
...(server.logging ? { logging: server.logging } : {}),
|
||||||
...(server.allowedTools ? { allowedTools: [...server.allowedTools] } : {}),
|
...(server.allowedTools ? { allowedTools: [...server.allowedTools] } : {}),
|
||||||
|
|||||||
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 { loadServerDefinitions } from './config.js';
|
||||||
export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.js';
|
export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.js';
|
||||||
export { createCallResult, describeConnectionIssue, wrapCallResult } 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 { callOnce, createRuntime } from './runtime.js';
|
||||||
export type { GeneratedRuntimeContext } from './generated-daemon-runtime.js';
|
export type { GeneratedRuntimeContext } from './generated-daemon-runtime.js';
|
||||||
export { createGeneratedKeepAliveRuntime } 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';
|
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 includeOverride = parseList(process.env.MCPORTER_KEEPALIVE);
|
||||||
const excludeOverride = parseList(process.env.MCPORTER_DISABLE_KEEPALIVE ?? process.env.MCPORTER_NO_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: 'chrome-devtools', fragments: ['chrome-devtools-mcp'] },
|
||||||
{ label: 'mobile-mcp', fragments: ['@mobilenext/mobile-mcp', 'mobile-mcp'] },
|
{ label: 'mobile-mcp', fragments: ['@mobilenext/mobile-mcp', 'mobile-mcp'] },
|
||||||
{ label: 'playwright', fragments: ['@playwright/mcp', 'playwright/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'];
|
const CHROME_DEVTOOLS_URL_PLACEHOLDERS = [String.raw`\${CHROME_DEVTOOLS_URL}`, '$env:CHROME_DEVTOOLS_URL'];
|
||||||
|
|||||||
33
src/oauth-client-info.ts
Normal file
33
src/oauth-client-info.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { OAuthClientInformationMixed } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
|
import type { ServerDefinition } from './config.js';
|
||||||
|
|
||||||
|
export function buildStaticClientInformation(
|
||||||
|
definition: ServerDefinition,
|
||||||
|
options: { redirectUrl?: URL | string } = {}
|
||||||
|
): OAuthClientInformationMixed | undefined {
|
||||||
|
if (!definition.oauthClientId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const clientSecret = resolveOAuthClientSecret(definition);
|
||||||
|
return {
|
||||||
|
client_id: definition.oauthClientId,
|
||||||
|
...(clientSecret ? { client_secret: clientSecret } : {}),
|
||||||
|
...(options.redirectUrl ? { redirect_uris: [options.redirectUrl.toString()] } : {}),
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
...(definition.oauthTokenEndpointAuthMethod
|
||||||
|
? { token_endpoint_auth_method: definition.oauthTokenEndpointAuthMethod }
|
||||||
|
: {}),
|
||||||
|
} as OAuthClientInformationMixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
|
||||||
|
if (definition.oauthClientSecretEnv) {
|
||||||
|
const value = process.env[definition.oauthClientSecretEnv];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return definition.oauthClientSecret;
|
||||||
|
}
|
||||||
@ -1,10 +1,18 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
import { discoverOAuthServerInfo, refreshAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||||
|
import type {
|
||||||
|
OAuthClientInformationMixed,
|
||||||
|
OAuthProtectedResourceMetadata,
|
||||||
|
OAuthTokens,
|
||||||
|
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
|
import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk/shared/auth-utils.js';
|
||||||
import type { ServerDefinition } from './config.js';
|
import type { ServerDefinition } from './config.js';
|
||||||
import { readJsonFile, writeJsonFile } from './fs-json.js';
|
import { readJsonFile, writeJsonFile, writeTextFileAtomic } from './fs-json.js';
|
||||||
import type { Logger } from './logging.js';
|
import type { Logger } from './logging.js';
|
||||||
|
import { buildStaticClientInformation } from './oauth-client-info.js';
|
||||||
import { clearVaultEntry, getOAuthVaultPath, loadVaultEntry, saveVaultEntry } from './oauth-vault.js';
|
import { clearVaultEntry, getOAuthVaultPath, loadVaultEntry, saveVaultEntry } from './oauth-vault.js';
|
||||||
import { legacyMcporterDir } from './paths.js';
|
import { legacyMcporterDir } from './paths.js';
|
||||||
|
|
||||||
@ -23,6 +31,102 @@ export interface OAuthPersistence {
|
|||||||
clear(scope: OAuthClearScope): Promise<void>;
|
clear(scope: OAuthClearScope): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoredOAuthTokens = OAuthTokens & {
|
||||||
|
expires_at?: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_EXPIRY_SKEW_SECONDS = 60;
|
||||||
|
|
||||||
|
function withStoredExpiry(tokens: OAuthTokens): OAuthTokens {
|
||||||
|
const stored = tokens as StoredOAuthTokens;
|
||||||
|
if (typeof stored.expires_at === 'number' || typeof stored.expiresAt === 'number') {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
if (typeof tokens.expires_in === 'number' && Number.isFinite(tokens.expires_in)) {
|
||||||
|
return {
|
||||||
|
...tokens,
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + tokens.expires_in,
|
||||||
|
} as OAuthTokens;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenExpirySeconds(tokens: OAuthTokens): number | undefined {
|
||||||
|
const stored = tokens as StoredOAuthTokens;
|
||||||
|
for (const candidate of [stored.expires_at, stored.expiresAt]) {
|
||||||
|
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
return expiresAt <= Math.floor(Date.now() / 1000) + skewSeconds;
|
||||||
|
}
|
||||||
|
return typeof tokens.expires_in === 'number' && typeof tokens.refresh_token === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceForRefresh(
|
||||||
|
serverUrl: URL,
|
||||||
|
resourceMetadata: OAuthProtectedResourceMetadata | undefined
|
||||||
|
): URL | undefined {
|
||||||
|
if (!resourceMetadata) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const defaultResource = resourceUrlFromServerUrl(serverUrl);
|
||||||
|
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
|
||||||
|
throw new Error(
|
||||||
|
`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 {
|
class DirectoryPersistence implements OAuthPersistence {
|
||||||
private readonly tokenPath: string;
|
private readonly tokenPath: string;
|
||||||
private readonly clientInfoPath: string;
|
private readonly clientInfoPath: string;
|
||||||
@ -48,17 +152,17 @@ class DirectoryPersistence implements OAuthPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async readTokens(): Promise<OAuthTokens | undefined> {
|
async readTokens(): Promise<OAuthTokens | undefined> {
|
||||||
return readJsonFile<OAuthTokens>(this.tokenPath);
|
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
await this.ensureDir();
|
await this.ensureDir();
|
||||||
await writeJsonFile(this.tokenPath, tokens);
|
await writeJsonFile(this.tokenPath, withStoredExpiry(tokens));
|
||||||
this.logger?.debug?.(`Saved tokens to ${this.tokenPath}`);
|
this.logger?.debug?.(`Saved tokens to ${this.tokenPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||||
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
|
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
||||||
@ -79,13 +183,35 @@ class DirectoryPersistence implements OAuthPersistence {
|
|||||||
|
|
||||||
async saveCodeVerifier(value: string): Promise<void> {
|
async saveCodeVerifier(value: string): Promise<void> {
|
||||||
await this.ensureDir();
|
await this.ensureDir();
|
||||||
await fs.writeFile(this.codeVerifierPath, value, 'utf8');
|
await writeTextFileAtomic(this.codeVerifierPath, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readState(): Promise<string | undefined> {
|
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);
|
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> {
|
async saveState(value: string): Promise<void> {
|
||||||
await this.ensureDir();
|
await this.ensureDir();
|
||||||
await writeJsonFile(this.statePath, value);
|
await writeJsonFile(this.statePath, value);
|
||||||
@ -131,7 +257,7 @@ class VaultPersistence implements OAuthPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
await saveVaultEntry(this.definition, { tokens });
|
await saveVaultEntry(this.definition, { tokens: withStoredExpiry(tokens) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||||
@ -281,7 +407,7 @@ export async function clearOAuthCaches(
|
|||||||
await legacy.clear(scope);
|
await legacy.clear(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.tokenCacheDir) {
|
if (definition.tokenCacheDir && scope === 'all') {
|
||||||
await fs.rm(definition.tokenCacheDir, { recursive: true, force: true });
|
await fs.rm(definition.tokenCacheDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,8 +436,220 @@ export async function readCachedAccessToken(
|
|||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const persistence = await buildOAuthPersistence(definition, logger);
|
const persistence = await buildOAuthPersistence(definition, logger);
|
||||||
const tokens = await persistence.readTokens();
|
const tokens = await persistence.readTokens();
|
||||||
if (tokens && typeof tokens.access_token === 'string' && tokens.access_token.trim().length > 0) {
|
if (!tokens || typeof tokens.access_token !== 'string' || tokens.access_token.trim().length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (definition.auth === 'refreshable_bearer') {
|
||||||
|
return await readExplicitRefreshableBearerToken(definition, persistence, tokens, logger);
|
||||||
|
}
|
||||||
|
if (!shouldRefreshCachedToken(tokens)) {
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
if (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.trim().length === 0) {
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const clientInformation = buildStaticClientInformation(definition) ?? (await persistence.readClientInfo());
|
||||||
|
if (!clientInformation) {
|
||||||
|
logger?.debug?.(
|
||||||
|
`Cached OAuth token for '${definition.name}' is expired, but no client information is available.`
|
||||||
|
);
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
if (definition.command.kind !== 'http') {
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
const serverInfo = await discoverOAuthServerInfo(definition.command.url);
|
||||||
|
const resource = resourceForRefresh(definition.command.url, serverInfo.resourceMetadata);
|
||||||
|
const refreshed = await refreshAuthorization(serverInfo.authorizationServerUrl, {
|
||||||
|
metadata: serverInfo.authorizationServerMetadata,
|
||||||
|
clientInformation,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
...(resource ? { resource } : {}),
|
||||||
|
});
|
||||||
|
await persistence.saveTokens(refreshed);
|
||||||
|
logger?.debug?.(`Refreshed cached OAuth access token for '${definition.name}' (non-interactive).`);
|
||||||
|
return refreshed.access_token;
|
||||||
|
} catch (error) {
|
||||||
|
logger?.debug?.(
|
||||||
|
`Failed to refresh cached OAuth token for '${definition.name}' non-interactively: ${
|
||||||
|
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;
|
return tokens.access_token;
|
||||||
}
|
}
|
||||||
return undefined;
|
}
|
||||||
|
|
||||||
|
async function readExplicitRefreshableBearerToken(
|
||||||
|
definition: ServerDefinition,
|
||||||
|
persistence: OAuthPersistence,
|
||||||
|
tokens: OAuthTokens,
|
||||||
|
logger?: Logger
|
||||||
|
): Promise<string> {
|
||||||
|
const refresh = definition.refresh;
|
||||||
|
const skewSeconds = refresh?.refreshSkewSeconds ?? TOKEN_EXPIRY_SKEW_SECONDS;
|
||||||
|
if (!shouldRefreshCachedToken(tokens, skewSeconds)) {
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
if (!refresh) {
|
||||||
|
throw new Error(`Cached bearer token for '${definition.name}' is expired, but refresh is not configured.`);
|
||||||
|
}
|
||||||
|
if (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.trim().length === 0) {
|
||||||
|
throw new Error(`Cached bearer token for '${definition.name}' is expired, but no refresh_token is available.`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshBearerToken(definition, tokens.refresh_token);
|
||||||
|
await persistence.saveTokens(refreshed);
|
||||||
|
logger?.debug?.(`Refreshed bearer access token for '${definition.name}' (non-interactive).`);
|
||||||
|
return refreshed.access_token;
|
||||||
|
} catch (error) {
|
||||||
|
logger?.debug?.(
|
||||||
|
`Failed to refresh bearer token for '${definition.name}' non-interactively: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to refresh cached bearer token for '${definition.name}': ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBearerToken(definition: ServerDefinition, refreshToken: string): Promise<OAuthTokens> {
|
||||||
|
const refresh = definition.refresh;
|
||||||
|
if (!refresh) {
|
||||||
|
throw new Error('Missing refresh configuration.');
|
||||||
|
}
|
||||||
|
const clientId = readEnvOrConfig(refresh.clientIdEnv, definition.oauthClientId);
|
||||||
|
const method = refresh.clientAuthMethod ?? definition.oauthTokenEndpointAuthMethod ?? 'client_secret_basic';
|
||||||
|
const clientSecret = method === 'none' ? undefined : readClientSecret(definition, refresh.clientSecretEnv);
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (method === 'client_secret_post') {
|
||||||
|
if (clientId) {
|
||||||
|
body.set('client_id', clientId);
|
||||||
|
}
|
||||||
|
if (clientSecret) {
|
||||||
|
body.set('client_secret', clientSecret);
|
||||||
|
}
|
||||||
|
} else if (method === 'none') {
|
||||||
|
if (clientId) {
|
||||||
|
body.set('client_id', clientId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error(`Refresh client credentials are required for '${method}'.`);
|
||||||
|
}
|
||||||
|
headers.authorization = `Basic ${Buffer.from(
|
||||||
|
`${formEncodeCredential(clientId)}:${formEncodeCredential(clientSecret)}`
|
||||||
|
).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(refresh.tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token endpoint returned HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = normalizeBearerTokenResponse(await response.json());
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
...(payload.refresh_token ? {} : { refresh_token: refreshToken }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBearerTokenResponse(value: unknown): OAuthTokens {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
throw new Error('Token endpoint did not return a JSON object.');
|
||||||
|
}
|
||||||
|
const payload = value as Record<string, unknown>;
|
||||||
|
if (typeof payload.access_token !== 'string' || payload.access_token.trim().length === 0) {
|
||||||
|
throw new Error('Token endpoint did not return an access_token.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
access_token: payload.access_token,
|
||||||
|
token_type: typeof payload.token_type === 'string' && payload.token_type ? payload.token_type : 'Bearer',
|
||||||
|
...(typeof payload.id_token === 'string' ? { id_token: payload.id_token } : {}),
|
||||||
|
...(typeof payload.scope === 'string' ? { scope: payload.scope } : {}),
|
||||||
|
...(typeof payload.refresh_token === 'string' && payload.refresh_token
|
||||||
|
? { refresh_token: payload.refresh_token }
|
||||||
|
: {}),
|
||||||
|
...coerceExpiresIn(payload.expires_in),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceExpiresIn(value: unknown): Pick<OAuthTokens, 'expires_in'> {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return { expires_in: value };
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return { expires_in: parsed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvOrConfig(envName: string | undefined, fallback: string | undefined): string | undefined {
|
||||||
|
if (!envName) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = process.env[envName];
|
||||||
|
if (value === undefined || value.trim().length === 0) {
|
||||||
|
throw new Error(`Environment variable '${envName}' is required for bearer token refresh.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formEncodeCredential(value: string): string {
|
||||||
|
return new URLSearchParams([['', value]]).toString().slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readClientSecret(
|
||||||
|
definition: ServerDefinition,
|
||||||
|
refreshClientSecretEnv: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (refreshClientSecretEnv) {
|
||||||
|
return readEnvOrConfig(refreshClientSecretEnv, undefined);
|
||||||
|
}
|
||||||
|
return resolveOAuthClientSecret(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
|
||||||
|
if (definition.oauthClientSecretEnv) {
|
||||||
|
const value = process.env[definition.oauthClientSecretEnv];
|
||||||
|
if (value === undefined || value.trim().length === 0) {
|
||||||
|
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return definition.oauthClientSecret;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
import type { ServerDefinition } from './config.js';
|
import type { ServerDefinition } from './config.js';
|
||||||
import { readJsonFile, writeJsonFile } from './fs-json.js';
|
import { readJsonFile, withFileLock, writeJsonFile } from './fs-json.js';
|
||||||
import { mcporterDir } from './paths.js';
|
import { mcporterDir } from './paths.js';
|
||||||
|
|
||||||
type VaultKey = string;
|
type VaultKey = string;
|
||||||
@ -23,35 +22,49 @@ interface VaultFile {
|
|||||||
entries: Record<VaultKey, VaultEntry>;
|
entries: Record<VaultKey, VaultEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VaultReadState {
|
||||||
|
vault: VaultFile;
|
||||||
|
needsRepair: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SameUrlCredentials {
|
||||||
|
tokens?: OAuthTokens;
|
||||||
|
clientInfo?: OAuthClientInformationMixed;
|
||||||
|
sourceKeys: VaultKey[];
|
||||||
|
}
|
||||||
|
|
||||||
export function getOAuthVaultPath(): string {
|
export function getOAuthVaultPath(): string {
|
||||||
return path.join(mcporterDir('data'), 'credentials.json');
|
return path.join(mcporterDir('data'), 'credentials.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readVault(): Promise<VaultFile> {
|
async function readVaultState(): Promise<VaultReadState> {
|
||||||
let shouldRewrite = false;
|
|
||||||
try {
|
try {
|
||||||
const existing = await readJsonFile<VaultFile>(getOAuthVaultPath());
|
const existing = await readJsonFile<VaultFile>(getOAuthVaultPath());
|
||||||
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === 'object') {
|
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === 'object') {
|
||||||
return existing;
|
return { vault: existing, needsRepair: false };
|
||||||
}
|
}
|
||||||
// Unexpected shape; rewrite.
|
if (existing !== undefined) {
|
||||||
shouldRewrite = true;
|
return { vault: emptyVault(), needsRepair: true };
|
||||||
} catch {
|
}
|
||||||
// Corrupt or unreadable vault; reset to empty.
|
} catch (error) {
|
||||||
shouldRewrite = true;
|
if (!(error instanceof SyntaxError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { vault: emptyVault(), needsRepair: true };
|
||||||
}
|
}
|
||||||
const empty: VaultFile = { version: 1, entries: {} };
|
return { vault: emptyVault(), needsRepair: false };
|
||||||
if (shouldRewrite) {
|
}
|
||||||
await writeVault(empty);
|
|
||||||
}
|
async function readVault(): Promise<VaultFile> {
|
||||||
return empty;
|
return (await readVaultState()).vault;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyVault(): VaultFile {
|
||||||
|
return { version: 1, entries: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeVault(contents: VaultFile): Promise<void> {
|
async function writeVault(contents: VaultFile): Promise<void> {
|
||||||
const filePath = getOAuthVaultPath();
|
await writeJsonFile(getOAuthVaultPath(), contents);
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
await writeJsonFile(filePath, contents);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
|
export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
|
||||||
@ -69,53 +82,179 @@ export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
|
|||||||
|
|
||||||
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
|
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
|
||||||
const vault = await readVault();
|
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> {
|
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
|
||||||
const vault = await readVault();
|
await withFileLock(getOAuthVaultPath(), async () => {
|
||||||
const key = vaultKeyForDefinition(definition);
|
const vault = await readVault();
|
||||||
const current = vault.entries[key] ?? {
|
const key = vaultKeyForDefinition(definition);
|
||||||
serverName: definition.name,
|
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
|
||||||
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
|
const fallback = findSameUrlCredentials(vault, definition, key, existing);
|
||||||
updatedAt: new Date().toISOString(),
|
const current = existing ?? {
|
||||||
};
|
serverName: definition.name,
|
||||||
vault.entries[key] = {
|
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
|
||||||
...current,
|
updatedAt: new Date().toISOString(),
|
||||||
...patch,
|
};
|
||||||
updatedAt: new Date().toISOString(),
|
vault.entries[key] = {
|
||||||
};
|
...current,
|
||||||
await writeVault(vault);
|
...patch,
|
||||||
|
clientInfo:
|
||||||
|
patch.clientInfo ?? current.clientInfo ?? (patch.tokens && !current.tokens ? fallback.clientInfo : undefined),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeVault(vault);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearVaultEntry(
|
export async function clearVaultEntry(
|
||||||
definition: ServerDefinition,
|
definition: ServerDefinition,
|
||||||
scope: 'all' | 'tokens' | 'client' | 'verifier' | 'state'
|
scope: 'all' | 'tokens' | 'client' | 'verifier' | 'state'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const vault = await readVault();
|
|
||||||
const key = vaultKeyForDefinition(definition);
|
const key = vaultKeyForDefinition(definition);
|
||||||
const existing = vault.entries[key];
|
await withFileLock(getOAuthVaultPath(), async () => {
|
||||||
if (!existing) {
|
const { vault, needsRepair } = await readVaultState();
|
||||||
return;
|
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
|
||||||
}
|
const fallback = findSameUrlCredentials(vault, definition, key, existing);
|
||||||
if (scope === 'all') {
|
const inheritedKeys = scope === 'all' ? legacyOAuthRenameKeys(vault, definition, key) : fallback.sourceKeys;
|
||||||
delete vault.entries[key];
|
if (!existing && inheritedKeys.length === 0) {
|
||||||
} else {
|
if (needsRepair) {
|
||||||
const updated: VaultEntry = { ...existing };
|
await writeVault(vault);
|
||||||
if (scope === 'tokens') {
|
}
|
||||||
delete updated.tokens;
|
return;
|
||||||
}
|
}
|
||||||
if (scope === 'client') {
|
if (scope === 'all') {
|
||||||
delete updated.clientInfo;
|
delete vault.entries[key];
|
||||||
|
} else if (existing) {
|
||||||
|
const updated: VaultEntry = { ...existing };
|
||||||
|
if (scope === 'tokens') {
|
||||||
|
delete updated.tokens;
|
||||||
|
}
|
||||||
|
if (scope === 'client') {
|
||||||
|
delete updated.clientInfo;
|
||||||
|
}
|
||||||
|
if (scope === 'verifier') {
|
||||||
|
delete updated.codeVerifier;
|
||||||
|
}
|
||||||
|
if (scope === 'state') {
|
||||||
|
delete updated.state;
|
||||||
|
}
|
||||||
|
updated.updatedAt = new Date().toISOString();
|
||||||
|
vault.entries[key] = updated;
|
||||||
}
|
}
|
||||||
if (scope === 'verifier') {
|
for (const fallbackKey of inheritedKeys) {
|
||||||
delete updated.codeVerifier;
|
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;
|
||||||
}
|
}
|
||||||
if (scope === 'state') {
|
await writeVault(vault);
|
||||||
delete updated.state;
|
});
|
||||||
}
|
|
||||||
updated.updatedAt = new Date().toISOString();
|
|
||||||
vault.entries[key] = updated;
|
|
||||||
}
|
|
||||||
await writeVault(vault);
|
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/oauth.ts
89
src/oauth.ts
@ -9,12 +9,23 @@ import type {
|
|||||||
OAuthTokens,
|
OAuthTokens,
|
||||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
import type { ServerDefinition } from './config.js';
|
import type { ServerDefinition } from './config.js';
|
||||||
|
import { buildStaticClientInformation } from './oauth-client-info.js';
|
||||||
import type { OAuthPersistence } from './oauth-persistence.js';
|
import type { OAuthPersistence } from './oauth-persistence.js';
|
||||||
import { buildOAuthPersistence } from './oauth-persistence.js';
|
import { buildOAuthPersistence } from './oauth-persistence.js';
|
||||||
|
|
||||||
const CALLBACK_HOST = '127.0.0.1';
|
const CALLBACK_HOST = '127.0.0.1';
|
||||||
const CALLBACK_PATH = '/callback';
|
const CALLBACK_PATH = '/callback';
|
||||||
|
|
||||||
|
export interface OAuthAuthorizationRequest {
|
||||||
|
authorizationUrl: string;
|
||||||
|
redirectUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthSessionOptions {
|
||||||
|
suppressBrowserLaunch?: boolean;
|
||||||
|
onAuthorizationUrl?: (request: OAuthAuthorizationRequest) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Deferred<T> {
|
interface Deferred<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
resolve: (value: T) => void;
|
resolve: (value: T) => void;
|
||||||
@ -74,7 +85,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
private readonly definition: ServerDefinition,
|
private readonly definition: ServerDefinition,
|
||||||
persistence: OAuthPersistence,
|
persistence: OAuthPersistence,
|
||||||
redirectUrl: URL,
|
redirectUrl: URL,
|
||||||
logger: OAuthLogger
|
logger: OAuthLogger,
|
||||||
|
private readonly options: OAuthSessionOptions = {}
|
||||||
) {
|
) {
|
||||||
this.redirectUrlValue = redirectUrl;
|
this.redirectUrlValue = redirectUrl;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
@ -96,7 +108,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
|
|
||||||
static async create(
|
static async create(
|
||||||
definition: ServerDefinition,
|
definition: ServerDefinition,
|
||||||
logger: OAuthLogger
|
logger: OAuthLogger,
|
||||||
|
options: OAuthSessionOptions = {}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
provider: PersistentOAuthClientProvider;
|
provider: PersistentOAuthClientProvider;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
@ -137,8 +150,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
// previous client registration is cached with a different redirect URI the
|
// previous client registration is cached with a different redirect URI the
|
||||||
// auth server will reject the request with `invalid_redirect_uri`. Clear
|
// auth server will reject the request with `invalid_redirect_uri`. Clear
|
||||||
// the stale registration so the next flow re-registers with the new URI.
|
// the stale registration so the next flow re-registers with the new URI.
|
||||||
// Wrapped in try/catch so persistence errors (malformed JSON, permission
|
// Wrapped in try/catch so non-recoverable persistence errors (for example,
|
||||||
// issues) close the already-bound callback server instead of leaking it.
|
// permission issues) close the already-bound callback server instead of leaking it.
|
||||||
if (usesDynamicPort) {
|
if (usesDynamicPort) {
|
||||||
try {
|
try {
|
||||||
const cachedClient = await persistence.readClientInfo();
|
const cachedClient = await persistence.readClientInfo();
|
||||||
@ -157,7 +170,7 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger);
|
const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger, options);
|
||||||
provider.attachServer(server);
|
provider.attachServer(server);
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
@ -184,7 +197,7 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
const error = parsed.searchParams.get('error');
|
const error = parsed.searchParams.get('error');
|
||||||
const receivedState = parsed.searchParams.get('state');
|
const receivedState = parsed.searchParams.get('state');
|
||||||
const expectedState = await this.persistence.readState();
|
const expectedState = await this.persistence.readState();
|
||||||
if (expectedState && receivedState && receivedState !== expectedState) {
|
if (expectedState && receivedState !== expectedState) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.setHeader('Content-Type', 'text/html');
|
res.setHeader('Content-Type', 'text/html');
|
||||||
res.end('<html><body><h1>Authorization failed</h1><p>Invalid OAuth state</p></body></html>');
|
res.end('<html><body><h1>Authorization failed</h1><p>Invalid OAuth state</p></body></html>');
|
||||||
@ -237,7 +250,7 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
||||||
const staticClient = buildStaticClientInformation(this.definition, this.redirectUrlValue);
|
const staticClient = buildStaticClientInformation(this.definition, { redirectUrl: this.redirectUrlValue });
|
||||||
if (staticClient) {
|
if (staticClient) {
|
||||||
return staticClient;
|
return staticClient;
|
||||||
}
|
}
|
||||||
@ -258,11 +271,19 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||||
this.logger.info(`Authorization required for ${this.definition.name}. Opening browser...`);
|
|
||||||
this.authorizationRedirectStarted = true;
|
this.authorizationRedirectStarted = true;
|
||||||
this.ensureAuthorizationDeferred();
|
this.ensureAuthorizationDeferred();
|
||||||
__oauthInternals.openExternal(authorizationUrl.toString());
|
const request = {
|
||||||
this.logger.warn(`If the browser did not open, visit ${authorizationUrl.toString()} manually.`);
|
authorizationUrl: authorizationUrl.toString(),
|
||||||
|
redirectUrl: this.redirectUrlValue.toString(),
|
||||||
|
} satisfies OAuthAuthorizationRequest;
|
||||||
|
if (this.options.suppressBrowserLaunch) {
|
||||||
|
await this.options.onAuthorizationUrl?.(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.info(`Authorization required for ${this.definition.name}. Opening browser...`);
|
||||||
|
__oauthInternals.openExternal(request.authorizationUrl);
|
||||||
|
this.logger.warn(`If the browser did not open, visit ${request.authorizationUrl} manually.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAuthorizationRedirectStarted(): boolean {
|
hasAuthorizationRedirectStarted(): boolean {
|
||||||
@ -302,10 +323,12 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
if (!this.server) {
|
if (!this.server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve) => {
|
this.server.closeAllConnections?.();
|
||||||
this.server?.close(() => resolve());
|
const server = this.server;
|
||||||
});
|
|
||||||
this.server = undefined;
|
this.server = undefined;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAuthorizationDeferred(): Deferred<string> {
|
private ensureAuthorizationDeferred(): Deferred<string> {
|
||||||
@ -327,8 +350,12 @@ export interface OAuthSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createOAuthSession spins up a file-backed OAuth provider and callback server for the target definition.
|
// createOAuthSession spins up a file-backed OAuth provider and callback server for the target definition.
|
||||||
export async function createOAuthSession(definition: ServerDefinition, logger: OAuthLogger): Promise<OAuthSession> {
|
export async function createOAuthSession(
|
||||||
const { provider, close } = await PersistentOAuthClientProvider.create(definition, logger);
|
definition: ServerDefinition,
|
||||||
|
logger: OAuthLogger,
|
||||||
|
options: OAuthSessionOptions = {}
|
||||||
|
): Promise<OAuthSession> {
|
||||||
|
const { provider, close } = await PersistentOAuthClientProvider.create(definition, logger, options);
|
||||||
const waitForAuthorizationCode = () => provider.waitForAuthorizationCode();
|
const waitForAuthorizationCode = () => provider.waitForAuthorizationCode();
|
||||||
const hasAuthorizationRedirectStarted = () => provider.hasAuthorizationRedirectStarted();
|
const hasAuthorizationRedirectStarted = () => provider.hasAuthorizationRedirectStarted();
|
||||||
return {
|
return {
|
||||||
@ -356,38 +383,6 @@ function firstRedirectUri(client: OAuthClientInformationMixed | undefined): stri
|
|||||||
return typeof first === 'string' ? first : undefined;
|
return typeof first === 'string' ? first : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStaticClientInformation(
|
|
||||||
definition: ServerDefinition,
|
|
||||||
redirectUrl: URL
|
|
||||||
): OAuthClientInformationMixed | undefined {
|
|
||||||
if (!definition.oauthClientId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const clientSecret = resolveOAuthClientSecret(definition);
|
|
||||||
const metadata = {
|
|
||||||
client_id: definition.oauthClientId,
|
|
||||||
...(clientSecret ? { client_secret: clientSecret } : {}),
|
|
||||||
redirect_uris: [redirectUrl.toString()],
|
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
|
||||||
response_types: ['code'],
|
|
||||||
...(definition.oauthTokenEndpointAuthMethod
|
|
||||||
? { token_endpoint_auth_method: definition.oauthTokenEndpointAuthMethod }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
return metadata as OAuthClientInformationMixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
|
|
||||||
if (definition.oauthClientSecretEnv) {
|
|
||||||
const value = process.env[definition.oauthClientSecretEnv];
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return definition.oauthClientSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const __oauthInternals = {
|
export const __oauthInternals = {
|
||||||
openExternal,
|
openExternal,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -28,5 +28,10 @@ export function mcporterDir(kind: McporterPathKind): string {
|
|||||||
|
|
||||||
export function mcporterConfigCandidates(): string[] {
|
export function mcporterConfigCandidates(): string[] {
|
||||||
const base = mcporterDir('config');
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,32 +30,37 @@ interface CollectedCallContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractEnvelope(raw: unknown): ExtractedEnvelope {
|
function extractEnvelope(raw: unknown): ExtractedEnvelope {
|
||||||
|
return collectEnvelopeFields(raw, { content: null, structuredContent: null }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEnvelopeFields(raw: unknown, envelope: ExtractedEnvelope, depth: number): ExtractedEnvelope {
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!raw || typeof raw !== 'object') {
|
||||||
return { content: null, structuredContent: null };
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
const obj = raw as Record<string, unknown>;
|
const obj = raw as Record<string, unknown>;
|
||||||
let content: unknown[] | null = null;
|
let { content, structuredContent } = envelope;
|
||||||
let structuredContent: unknown = null;
|
|
||||||
|
|
||||||
if ('content' in obj && Array.isArray(obj.content)) {
|
if (!content && 'content' in obj && Array.isArray(obj.content)) {
|
||||||
content = obj.content as unknown[];
|
content = obj.content as unknown[];
|
||||||
}
|
}
|
||||||
if ('structuredContent' in obj) {
|
if (structuredContent === null && 'structuredContent' in obj) {
|
||||||
structuredContent = obj.structuredContent;
|
structuredContent = obj.structuredContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('raw' in obj && obj.raw && typeof obj.raw === 'object') {
|
const updated = { content, structuredContent };
|
||||||
const nested = obj.raw as Record<string, unknown>;
|
if (depth >= 2) {
|
||||||
if (!content && 'content' in nested && Array.isArray(nested.content)) {
|
return updated;
|
||||||
content = nested.content as unknown[];
|
|
||||||
}
|
|
||||||
if (structuredContent === null && 'structuredContent' in nested) {
|
|
||||||
structuredContent = nested.structuredContent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content, structuredContent };
|
let nested = updated;
|
||||||
|
if ('raw' in obj) {
|
||||||
|
nested = collectEnvelopeFields(obj.raw, nested, depth + 1);
|
||||||
|
}
|
||||||
|
if ('result' in obj) {
|
||||||
|
nested = collectEnvelopeFields(obj.result, nested, depth + 1);
|
||||||
|
}
|
||||||
|
return nested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// asString converts known content/value shapes into plain strings.
|
// asString converts known content/value shapes into plain strings.
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { analyzeConnectionError } from './error-classifier.js';
|
|||||||
import type { Logger } from './logging.js';
|
import type { Logger } from './logging.js';
|
||||||
|
|
||||||
export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger): ServerDefinition | undefined {
|
export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger): ServerDefinition | undefined {
|
||||||
if (definition.auth === 'oauth') {
|
if (definition.auth === 'oauth' || definition.auth === 'refreshable_bearer') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (definition.command.kind !== 'http') {
|
if (definition.command.kind !== 'http') {
|
||||||
|
|||||||
@ -4,26 +4,40 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
import type { Logger } from './logging.js';
|
import type { Logger } from './logging.js';
|
||||||
|
|
||||||
|
export interface CloseTransportAndWaitOptions {
|
||||||
|
readonly throwOnCloseError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// closeTransportAndWait closes transports and ensures backing processes exit cleanly.
|
// closeTransportAndWait closes transports and ensures backing processes exit cleanly.
|
||||||
export async function closeTransportAndWait(
|
export async function closeTransportAndWait(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
transport: Transport & { close(): Promise<void> }
|
transport: Transport & { close(): Promise<void> },
|
||||||
|
options: CloseTransportAndWaitOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const pidBeforeClose = getTransportPid(transport);
|
const pidBeforeClose = getTransportPid(transport);
|
||||||
const childProcess =
|
const childProcess =
|
||||||
transport instanceof StdioClientTransport
|
transport instanceof StdioClientTransport
|
||||||
? ((transport as unknown as { _process?: ChildProcess | null })._process ?? null)
|
? ((transport as unknown as { _process?: ChildProcess | null })._process ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
let closeError: unknown;
|
||||||
try {
|
try {
|
||||||
await transport.close();
|
await transport.close();
|
||||||
} catch (error) {
|
} 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) {
|
if (childProcess) {
|
||||||
await waitForChildClose(childProcess, 1_000).catch(() => {});
|
await waitForChildClose(childProcess, 1_000).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (closeError) {
|
||||||
|
throw closeError;
|
||||||
|
}
|
||||||
|
|
||||||
if (!pidBeforeClose) {
|
if (!pidBeforeClose) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user