Compare commits
135 Commits
codex/tool
...
main
| 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 | ||
|
|
3648a92b2b | ||
|
|
9aab8df289 | ||
|
|
c0e251babe | ||
|
|
6012708bf3 | ||
|
|
e6b451aee3 | ||
|
|
f412d42122 | ||
|
|
e2c0641c1e | ||
|
|
39b49ef3bd | ||
|
|
6f063bf585 | ||
|
|
761c11cb3b | ||
|
|
45b881d4ea | ||
|
|
8f816e719f | ||
|
|
3269b7c1c3 | ||
|
|
3fb4005640 | ||
|
|
b0861e494f | ||
|
|
0416c94298 | ||
|
|
b3e1c7c314 | ||
|
|
23d3f9ef8d | ||
|
|
7d345bc7db | ||
|
|
026eb28cf4 | ||
|
|
6ed98602ef | ||
|
|
2100639cf3 | ||
|
|
fa8406b8fc | ||
|
|
b4504a6a61 | ||
|
|
be965df63e | ||
|
|
a5468c409b | ||
|
|
efb72cea4a | ||
|
|
bb6e64617a | ||
|
|
eb8986cd10 | ||
|
|
dd33721d89 | ||
|
|
caa00dd3a4 | ||
|
|
75dba26173 | ||
|
|
db6a199cd5 | ||
|
|
07ac8ea4c0 | ||
|
|
0e50f2b564 | ||
|
|
5d8e64d5d5 | ||
|
|
a64bdda3f7 | ||
|
|
d9eda97abe | ||
|
|
88237703e2 | ||
|
|
c2c256db7e | ||
|
|
b09ce5b20d | ||
|
|
3f4f8dc317 | ||
|
|
45dcb6561e | ||
|
|
7691ba812d | ||
|
|
41fa8cb06e | ||
|
|
25e68730a9 | ||
|
|
b680923ec5 | ||
|
|
6e597cf59f | ||
|
|
7ffbd52bf7 | ||
|
|
e1a35f8d91 | ||
|
|
6308557885 | ||
|
|
324fb7a00e | ||
|
|
c3475bd01d | ||
|
|
7515dd3412 | ||
|
|
098791a5cb | ||
|
|
9514e428b9 | ||
|
|
7bfc4736aa |
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
|
||||
58
.github/workflows/ci.yml
vendored
58
.github/workflows/ci.yml
vendored
@ -7,23 +7,71 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ matrix.os }})
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
|
||||
- run: corepack enable
|
||||
- run: corepack prepare pnpm@10.22.0 --activate
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- 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 --version
|
||||
- run: pnpm check
|
||||
if: matrix.os != 'macos-15'
|
||||
|
||||
- name: Check without type-aware oxlint
|
||||
if: matrix.os == 'macos-15'
|
||||
run: pnpm format:check && pnpm typecheck
|
||||
|
||||
- name: Verify generated schema is committed
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
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
|
||||
|
||||
- name: Pack npm artifact
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: pnpm pack --pack-destination /tmp
|
||||
|
||||
- run: pnpm test
|
||||
env:
|
||||
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
|
||||
53
.github/workflows/pages.yml
vendored
Normal file
53
.github/workflows/pages.yml
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'scripts/build-docs-site.mjs'
|
||||
- 'scripts/docs-site-assets.mjs'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Build docs site
|
||||
run: node scripts/build-docs-site.mjs
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: dist/docs-site
|
||||
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
63
.github/workflows/update-homebrew-tap.yml
vendored
Normal file
63
.github/workflows/update-homebrew-tap.yml
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
name: Update Homebrew Tap
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish to the tap (for example, v0.10.1)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-homebrew-tap:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ inputs.tag || github.event.release.tag_name }}"
|
||||
if [[ -z "$tag" ]]; then
|
||||
echo "Missing release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "request_id=mcporter-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Dispatch tap update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$GH_TOKEN"
|
||||
gh workflow run update-formula.yml \
|
||||
--repo steipete/homebrew-tap \
|
||||
-f formula=mcporter \
|
||||
-f tag="${{ steps.release.outputs.tag }}" \
|
||||
-f repository=openclaw/mcporter \
|
||||
-f artifact_url='https://github.com/openclaw/mcporter/releases/download/{tag}/mcporter-{version}.tgz' \
|
||||
-f request_id="${{ steps.release.outputs.request_id }}"
|
||||
|
||||
- name: Wait for tap update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for _ in {1..20}; do
|
||||
run_id="$(gh run list --repo steipete/homebrew-tap --workflow update-formula.yml --json databaseId,displayTitle --jq '.[] | select(.displayTitle | contains("${{ steps.release.outputs.request_id }}")) | .databaseId' | head -n1)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
gh run watch "$run_id" --repo steipete/homebrew-tap --exit-status
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "Timed out waiting for tap workflow to appear." >&2
|
||||
exit 1
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
dist
|
||||
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.
|
||||
- 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.
|
||||
|
||||
160
CHANGELOG.md
160
CHANGELOG.md
@ -1,9 +1,167 @@
|
||||
# mcporter Changelog
|
||||
|
||||
## [0.8.2] - Unreleased
|
||||
## [0.12.1] - 2026-06-18
|
||||
|
||||
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
|
||||
|
||||
### Config
|
||||
|
||||
- Skip imported server entries with unresolvable editor-specific environment placeholders, and allow later valid duplicates to take effect without relaxing validation for local config. (PR #209, thanks @Loveacup)
|
||||
|
||||
### OAuth
|
||||
|
||||
- Treat corrupt cached OAuth tokens and client metadata as missing so connections can re-authenticate, while keeping corrupt callback state data fail-closed. (Issue #207, thanks @KrasimirKralev)
|
||||
|
||||
### Tooling / Dependencies
|
||||
|
||||
- Refresh development dependencies and security overrides, including Vite, esbuild, and Hono.
|
||||
|
||||
## [0.12.0] - 2026-06-10
|
||||
|
||||
### OAuth
|
||||
|
||||
- Add cache-friendly `disableOAuth` support across headless runtime, CLI, daemon, proxy, and `callOnce` paths so callers can suppress interactive OAuth without losing connection reuse. (Issues #197, #199, #201, thanks @feniix)
|
||||
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
|
||||
- Prevent concurrent OAuth vault updates from briefly exposing empty lock files and losing credential entries under load.
|
||||
|
||||
### CLI
|
||||
|
||||
- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/<server>`, exposing one keep-alive server with original tool names while preserving aggregate `/mcp` namespacing. (PR #194, thanks @zm2231)
|
||||
- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123)
|
||||
- Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231)
|
||||
- Return a non-zero exit code for explicit `mcporter list <unknown-server>` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674)
|
||||
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
|
||||
- Keep CloudBase MCP alive by default so device-code authentication can finish polling and persist credentials after returning `AUTH_PENDING`. (PR #193, thanks @sevzq)
|
||||
- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr)
|
||||
|
||||
### Tooling / Dependencies
|
||||
|
||||
- Refresh development dependencies and satisfy the stricter `oxlint` check.
|
||||
|
||||
## [0.11.3] - 2026-05-21
|
||||
|
||||
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)
|
||||
|
||||
## [0.11.2] - 2026-05-21
|
||||
|
||||
### CLI
|
||||
|
||||
- Add `mcporter list --status`, `--exit-code`, and `--quiet` for concise server health checks without introducing a separate health command.
|
||||
- Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc)
|
||||
- Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett)
|
||||
- Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff)
|
||||
- Add the documented top-level `daemonIdleTimeoutMs` config to shut down inactive keep-alive daemons. (Issue #174, thanks @jarek083)
|
||||
|
||||
## [0.11.1] - 2026-05-14
|
||||
|
||||
### CLI
|
||||
|
||||
- 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
|
||||
|
||||
### CLI
|
||||
|
||||
- Keep keep-alive daemon retry diagnostics on stderr so `mcporter call --output json` stdout stays parseable after a daemon recovery. (PR #163 / issue #160, thanks @clawSean)
|
||||
- Increase the default OAuth browser wait from 60 seconds to 5 minutes so hosted MCP sign-ins have enough time for account and permission review.
|
||||
- Skip the redundant daemon `status` preflight for warm keep-alive access, cutting one socket round-trip from each routed list/call/resource request while preserving stale-config and dead-daemon recovery.
|
||||
- Route explicit default keep-alive calls like `chrome-devtools.list_pages` through a daemon-only fast path, avoiding full runtime startup on warm calls.
|
||||
- Further reduce warm keep-alive call startup by avoiding runtime/config schema imports on CLI boot and using a narrower daemon call path for simple explicit calls.
|
||||
- Keep single-server `mcporter list` non-interactive by reusing cached OAuth without launching new auth flows, and clamp oversized OAuth startup errors so HTML responses do not flood stdout/stderr.
|
||||
- Label non-timeout `mcporter list <server>` failures as unavailable instead of timed out.
|
||||
- Return concise/structured `mcporter resource` errors for servers that do not implement MCP resources instead of dumping SDK stack traces.
|
||||
- Refresh Context7 examples for the live `resolve-library-id` and `query-docs` schemas.
|
||||
- Make `generate-cli --help`, `inspect-cli --help`, and `emit-ts --help` print command help before flag parsing.
|
||||
- Auto-correct near-miss tool names when a server reports an unknown tool as MCP `isError` content instead of throwing.
|
||||
- Keep auto-correct diagnostics on stderr for `mcporter call --output json/raw` so stdout stays parseable.
|
||||
- Make generated CLIs keep `--output json` parseable for plain text MCP results by falling back to the raw JSON envelope.
|
||||
|
||||
### Config
|
||||
|
||||
- Preserve existing stdio executable paths that contain spaces instead of
|
||||
splitting them as inline command strings, so app bundle helpers like Hopper's
|
||||
MCP server can be configured directly.
|
||||
|
||||
### Tooling
|
||||
|
||||
- Build `dist/` once before the Vitest suite instead of letting parallel integration tests rebuild it mid-run.
|
||||
|
||||
## [0.10.1] - 2026-05-04
|
||||
|
||||
### CLI
|
||||
|
||||
- Fix Bun-compiled standalone binaries so `generate-cli --compile` can compile generated CLIs from empty directories by staging the matching published `mcporter` package dependencies when no local package tree is available.
|
||||
|
||||
### Tests
|
||||
|
||||
- Add an opt-in standalone Bun release-binary smoke for the empty-directory generated CLI compile path.
|
||||
|
||||
## [0.10.0] - 2026-05-04
|
||||
|
||||
### CLI
|
||||
|
||||
- Return a non-zero exit code when MCP tool results are marked `isError`, and preserve that status through the forced-exit cleanup path. (PR #154 / issue #153, thanks @jlapenna)
|
||||
- Give forced-exit cleanup a short stdout/stderr flush window so large JSON output is not truncated when `mcporter` is run from `child_process`. (PR #151 / issue #145, thanks @yuhp)
|
||||
- Treat `key:=value` as a compatibility alias for `key=value`, avoiding malformed keys such as `price:`. (PR #150 / issue #100, thanks @solomonneas)
|
||||
- Restore `mcporter call --key value` / `--key=value` tool arguments, including JSON array/object coercion, `--json -` stdin payloads, schema-aware bare string-to-array wrapping, and kebab-case to camelCase field mapping. (Issues #119 and #126)
|
||||
- Quote generated `emit-ts` members for tool names that are not valid TypeScript identifiers. (PR #149 / issue #30, thanks @solomonneas)
|
||||
- Resolve relative stdio args in generated CLI bundles against the generated script location instead of the caller's current directory. (PR #148 / issue #56, thanks @solomonneas)
|
||||
- Print OAuth manual-completion URLs at the default warning log level so headless users can copy them. (PR #143 / issue #139, thanks @stainlu)
|
||||
- Support repeatable `--header KEY=value` flags for ad-hoc HTTP servers and persisted ad-hoc entries. (Issue #117)
|
||||
- Let generated CLIs use `--raw` without also passing required flags, and parse array flags containing JSON object items. (Issues #102 and #103)
|
||||
- Preserve `auth: "oauth"` when an ad-hoc HTTP server is OAuth-promoted and saved with `--persist`. (Issue #82)
|
||||
- Let non-interactive `mcporter list` use existing OAuth token caches for HTTP servers even when older configs are missing `auth: "oauth"`. (Issue #137)
|
||||
- Fail OAuth flows immediately when the server never creates an authorization URL, instead of waiting for a browser callback that cannot arrive. (Issue #115)
|
||||
- Support `mcporter list server.tool --schema` to print a single tool's schema instead of the whole server. (Issue #116)
|
||||
- Surface MCP server `instructions` from the initialize response in single-server `mcporter list` text and JSON output. (Issue #76)
|
||||
- Add compact `mcporter list <server> --brief` / `--signatures` output for scanning signatures without doc blocks, examples, or schemas. (PR #144, thanks @yuhp)
|
||||
- Launch Bun-compiled macOS daemon children through `nohup` so Homebrew binaries can start keep-alive daemons in the background on macOS 26. (Issue #66)
|
||||
- Let generated CLIs use the keep-alive daemon for embedded servers with `lifecycle: "keep-alive"`, preserving stdio server state across separate generated-CLI invocations. (Issue #101)
|
||||
- Add `mcporter resource <server> [uri]` for listing and reading MCP resources, including keep-alive daemon routing. (Issue #134)
|
||||
|
||||
### Config
|
||||
|
||||
- Honor XDG Base Directory env vars for mcporter-owned config, data, cache, and state paths while preserving the legacy `~/.mcporter` fallback when XDG vars are unset. `MCPORTER_DAEMON_DIR`, `MCPORTER_CONFIG`, `--config`, and per-server `tokenCacheDir` remain explicit overrides. (Issue #155)
|
||||
- Support pre-registered OAuth clients via `oauthClientId`/`oauthClientSecretEnv` and token endpoint auth method overrides for providers without dynamic client registration. (Issue #132)
|
||||
- Respect configured stdio `cwd` values, including relative paths resolved from the config file and `~` home expansion. (PR #147 / issue #146, thanks @solomonneas)
|
||||
|
||||
### Tooling / Dependencies
|
||||
|
||||
- Updated `zod` to 4.4.3.
|
||||
|
||||
## [0.9.0] - 2026-04-18
|
||||
|
||||
### CLI
|
||||
|
||||
- Add per-server exact-name tool filtering with `allowedTools` and `blockedTools`, including config serialization and runtime call/list enforcement. (Rebuild of PR #39, thanks @tonylampada)
|
||||
- Escalate stuck stdio child-process shutdowns after close timeouts instead of treating the timeout as a clean exit. (PR #39, thanks @tonylampada)
|
||||
- Quote OAuth browser URLs when launching `cmd.exe` on Windows, preserving query parameters such as `redirect_uri`. (PR #136, thanks @cosminilie)
|
||||
- Document OAuth-protected server config setup with `mcporter config add --auth oauth` and `mcporter auth`. (PR #34, thanks @prateek)
|
||||
|
||||
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
\g<1>2026 Peter Steinberger
|
||||
Copyright (c) 2026 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
139
README.md
139
README.md
@ -17,13 +17,22 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
- **Zero-config discovery.** `createRuntime()` merges your home config (`~/.mcporter/mcporter.json[c]`) first, then `config/mcporter.json`, plus Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports, expands `${ENV}` placeholders, and pools connections so you can reuse transports across multiple calls.
|
||||
- **Zero-config discovery.** `createRuntime()` merges your home config (`~/.mcporter/mcporter.json[c]`, or `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when set) first, then `config/mcporter.json`, plus Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports, expands `${ENV}` placeholders, and pools connections so you can reuse transports across multiple calls.
|
||||
- **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration.
|
||||
- **Typed tool clients.** `mcporter emit-ts` emits `.d.ts` interfaces or ready-to-run client wrappers so agents/tests can call MCP servers with strong TypeScript types without hand-writing plumbing.
|
||||
- **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, and `.content()` helpers.
|
||||
- **Record/replay fixtures.** `mcporter record` captures MCP JSON-RPC traffic as NDJSON, and `mcporter replay` serves the same responses deterministically for offline debugging and redacted repros.
|
||||
- **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface.
|
||||
- **Ad-hoc connections.** Point the CLI at _any_ MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. Hosted MCPs that expect a browser login (Supabase, Vercel, etc.) are auto-detected—just run `mcporter auth <url>` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md).
|
||||
|
||||
## What's New in 0.11.0
|
||||
|
||||
- **Bridge mode.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP bridge with readable `server__tool` names.
|
||||
- **Headless OAuth.** `--no-browser`, vault seeding, cached-token refresh, and `auth: "refreshable_bearer"` cover non-interactive deployments.
|
||||
- **HTTP compatibility.** `httpFetch: "node-http1"` keeps providers that reject Node's built-in `fetch` working.
|
||||
- **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.11.0` is published on npm and Homebrew, and live/published install smokes are green.
|
||||
|
||||
## Quick Start
|
||||
|
||||
MCPorter auto-discovers the MCP servers you already configured in Cursor, Claude Code/Desktop, Codex, or local overrides. You can try it immediately with `npx`--no installation required. Need a full command reference (flags, modes, return types)? Check out [docs/cli-reference.md](docs/cli-reference.md).
|
||||
@ -52,6 +61,7 @@ npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz
|
||||
```
|
||||
|
||||
- Add `--json` to emit a machine-readable summary with per-server statuses (auth/offline/http/error counts) and, for single-server runs, the full tool schema payload.
|
||||
- Add `--status` for a concise single-server status check without tool docs, `--exit-code` to fail when any checked server is unhealthy, or `--quiet` for silent health gates.
|
||||
- Add `--verbose` to show every config source that registered the server name (primary first), both in text and JSON list output.
|
||||
|
||||
You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. Until you persist that definition, you still need to repeat the same URL/stdio flags for `mcporter call`—the printed slug only becomes reusable once you merge it into a config via `--persist` or `mcporter config add` (use `--scope home|project` to pick the write target). Follow up with `mcporter auth https://…` (or the same flag set) to finish OAuth without editing config. Full details live in [docs/adhoc.md](docs/adhoc.md).
|
||||
@ -118,8 +128,8 @@ Required parameters always show; optional parameters stay hidden unless (a) ther
|
||||
### Context7: fetch docs (no auth required)
|
||||
|
||||
```bash
|
||||
npx mcporter call context7.resolve-library-id libraryName=react
|
||||
npx mcporter call context7.get-library-docs context7CompatibleLibraryID=/websites/react_dev topic=hooks
|
||||
npx mcporter call context7.resolve-library-id query="React hooks docs" libraryName=react
|
||||
npx mcporter call context7.query-docs libraryId=/reactjs/react.dev query="useEffect cleanup"
|
||||
```
|
||||
|
||||
### Linear: search documentation (requires `LINEAR_API_KEY`)
|
||||
@ -133,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q
|
||||
```bash
|
||||
npx mcporter call chrome-devtools.take_snapshot
|
||||
npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")'
|
||||
npx mcporter call linear.create_comment issueId=LNR-123 body=@comment.md
|
||||
npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
|
||||
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
|
||||
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
||||
@ -153,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).
|
||||
- `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings.
|
||||
- `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion).
|
||||
- `key=@path` / `--key @path` (on `mcporter call`) -- read a named argument as exact UTF-8 text from a file; use `@@` for a literal leading `@`.
|
||||
- `--` (on `mcporter call`) -- stop flag parsing so the remaining tokens stay literal positional values, even when they start with `--`.
|
||||
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
|
||||
- `--status`, `--exit-code`, `--quiet` (on `mcporter list`) -- run concise server health checks through the existing list flow; `--quiet` suppresses output and exits 1 if anything checked is unhealthy.
|
||||
- `--output json/raw` (on `mcporter call`) -- when a connection fails, MCPorter prints the usual colorized hint and also emits a structured `{ server, tool, issue }` envelope so scripts can handle auth/offline/http errors programmatically.
|
||||
- `--json` (on `mcporter auth`) -- emit the same structured connection envelope whenever OAuth/transport setup fails, instead of throwing an error.
|
||||
- `--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.
|
||||
- `--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.
|
||||
@ -164,7 +178,7 @@ Helpful flags:
|
||||
|
||||
> Tip: You can skip the verb entirely—`mcporter firecrawl` automatically runs `mcporter list firecrawl`, and dotted tokens like `mcporter linear.list_issues` dispatch to the call command (typo fixes included).
|
||||
|
||||
Timeouts default to 30 s; override with `MCPORTER_LIST_TIMEOUT` or `MCPORTER_CALL_TIMEOUT` when you expect slow startups. OAuth browser handshakes get a separate 60 s grace period; pass `--oauth-timeout <ms>` (or export `MCPORTER_OAUTH_TIMEOUT_MS`) when you need the CLI to bail out faster while you diagnose stubborn auth flows.
|
||||
Timeouts default to 30 s; override with `MCPORTER_LIST_TIMEOUT` or `MCPORTER_CALL_TIMEOUT` when you expect slow startups. OAuth browser handshakes get a separate 5 minute grace period; pass `--oauth-timeout <ms>` (or export `MCPORTER_OAUTH_TIMEOUT_MS`) when you need the CLI to bail out faster while you diagnose stubborn auth flows.
|
||||
|
||||
### Try an MCP without editing config
|
||||
|
||||
@ -187,16 +201,17 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
|
||||
- Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env.
|
||||
- All other servers stay ephemeral; add `"lifecycle": "keep-alive"` to a server entry (or set `MCPORTER_KEEPALIVE=name`) when you want the daemon to manage it. You can also set `"lifecycle": "ephemeral"` (or `MCPORTER_DISABLE_KEEPALIVE=name`) to opt out.
|
||||
- The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon.
|
||||
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/<server>` for one selected keep-alive server with its original, unprefixed tool names.
|
||||
- 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
|
||||
|
||||
- **Function-call syntax.** Instead of juggling `--flag value`, you can call tools as `mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'`. The parser supports nested objects/arrays, lets you omit labels when you want to rely on schema order (e.g. `mcporter 'context7.resolve-library-id("react")'`), and surfaces schema validation errors clearly. Deep dive in [docs/call-syntax.md](docs/call-syntax.md).
|
||||
- **Function-call syntax.** Instead of juggling `--flag value`, you can call tools as `mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'`. The parser supports nested objects/arrays, lets you omit labels when you want to rely on schema order (e.g. `mcporter 'context7.resolve-library-id("React hooks docs", "react")'`), and surfaces schema validation errors clearly. Deep dive in [docs/call-syntax.md](docs/call-syntax.md).
|
||||
- **Flag shorthand still works.** Prefer CLI-style arguments? Stick with `mcporter linear.create_issue title=value team=value`, `title=value`, `title:value`, or even `title: value`—the CLI now normalizes all three forms.
|
||||
- **Unknown long flags fail fast.** `mcporter call server.tool --source import` now errors instead of silently turning `--source` into a positional tool argument. Use `source=import`, `--args '{"source":"import"}'`, or insert `--` before literal positional values that begin with `--`.
|
||||
- **Cheatsheet.** See [docs/tool-calling.md](docs/tool-calling.md) for a quick comparison of every supported call style (auto-inferred verbs, flags, function-calls, and ad-hoc URLs).
|
||||
- **Auto-correct.** If you typo a tool name, MCPorter inspects the server’s tool catalog, retries when the edit distance is tiny, and otherwise prints a `Did you mean …?` hint. The heuristic (and how to tune it) is captured in [docs/call-heuristic.md](docs/call-heuristic.md).
|
||||
- **Richer single-server output.** `mcporter list <server>` now prints TypeScript-style signatures, inline comments, return-shape hints, and command examples that mirror the new call syntax. Optional parameters stay hidden by default—add `--all-parameters` or `--schema` whenever you need the full JSON schema.
|
||||
- **Richer single-server output.** `mcporter list <server>` now prints TypeScript-style signatures, inline comments, return-shape hints, and command examples that mirror the new call syntax. Optional parameters stay hidden by default—add `--all-parameters` or `--schema` whenever you need the full JSON schema. Prefer a tighter scan? `mcporter list <server> --brief` (or `--signatures`) keeps just the compact signatures and optional summaries.
|
||||
|
||||
## Installation
|
||||
|
||||
@ -212,6 +227,12 @@ npx mcporter list
|
||||
pnpm add mcporter
|
||||
```
|
||||
|
||||
### Install globally with npm
|
||||
|
||||
```bash
|
||||
npm install -g mcporter
|
||||
```
|
||||
|
||||
### Homebrew (steipete/tap)
|
||||
|
||||
```bash
|
||||
@ -219,7 +240,7 @@ brew tap steipete/tap
|
||||
brew install steipete/tap/mcporter
|
||||
```
|
||||
|
||||
> The tap publishes alongside MCPorter 0.3.2. If you run into issues with an older tap install, run `brew update` before reinstalling.
|
||||
> The tap publishes alongside npm. If you run into issues with an older tap install, run `brew update` before reinstalling.
|
||||
|
||||
## One-shot calls from code
|
||||
|
||||
@ -235,7 +256,7 @@ const result = await callOnce({
|
||||
console.log(result); // raw MCP envelope
|
||||
```
|
||||
|
||||
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports), handles OAuth prompts, and closes transports when it finishes. It is ideal for manual runs or wiring MCPorter directly into an agent tool hook.
|
||||
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports), handles OAuth prompts, and closes transports when it finishes. It is ideal for manual runs or wiring MCPorter directly into an agent tool hook. In headless contexts, pass `disableOAuth: true` to suppress interactive OAuth and rely on cached tokens only — the library equivalent of the CLI's `--no-oauth` flag.
|
||||
|
||||
## Compose Automations with the Runtime
|
||||
|
||||
@ -246,7 +267,7 @@ const runtime = await createRuntime();
|
||||
|
||||
const tools = await runtime.listTools('context7');
|
||||
const result = await runtime.callTool('context7', 'resolve-library-id', {
|
||||
args: { libraryName: 'react' },
|
||||
args: { query: 'React hooks docs', libraryName: 'react' },
|
||||
});
|
||||
|
||||
console.log(result); // prints JSON/text automatically because the CLI pretty-prints by default
|
||||
@ -284,7 +305,7 @@ Friendly ergonomics baked into the proxy and result helpers:
|
||||
|
||||
Drop down to `runtime.callTool()` whenever you need explicit control over arguments, metadata, or streaming options.
|
||||
|
||||
Call `mcporter list <server>` any time you need the TypeScript-style signature, optional parameter hints, and sample invocations that match the CLI's function-call syntax.
|
||||
Call `mcporter list <server>` any time you need the TypeScript-style signature, optional parameter hints, and sample invocations that match the CLI's function-call syntax. Add `--brief` or `--signatures` when you only want compact signatures.
|
||||
|
||||
## Generate a Standalone CLI
|
||||
|
||||
@ -325,6 +346,10 @@ npx mcporter inspect-cli dist/context7.js # human-readable summary
|
||||
npx mcporter generate-cli --from dist/context7.js # replay with latest mcporter
|
||||
```
|
||||
|
||||
Agents should usually get one small skill per MCP server or workflow instead of
|
||||
a generic "all of mcporter" skill. See [docs/agent-skills.md](docs/agent-skills.md)
|
||||
for the pattern and a copyable template.
|
||||
|
||||
## Generate Typed Clients
|
||||
|
||||
Use `mcporter emit-ts` when you want strongly typed tooling without shipping a full CLI. It reuses the same signatures/doc blocks as `mcporter list`, so the generated headers stay in sync with what the CLI shows.
|
||||
@ -373,7 +398,7 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
|
||||
"env": { "npm_config_loglevel": "error" },
|
||||
},
|
||||
},
|
||||
@ -383,10 +408,11 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y
|
||||
|
||||
What MCPorter handles for you:
|
||||
|
||||
- `${VAR}`, `${VAR:-fallback}`, and `$env:VAR` interpolation for headers and env entries.
|
||||
- Automatic OAuth token caching under `~/.mcporter/<server>/` unless you override `tokenCacheDir`.
|
||||
- `${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`.
|
||||
- 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"]`.
|
||||
- `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
|
||||
|
||||
@ -397,6 +423,66 @@ npx mcporter config add notion https://mcp.notion.com/mcp --auth oauth
|
||||
npx mcporter auth notion
|
||||
```
|
||||
|
||||
On headless hosts, use `npx mcporter auth notion --no-browser` to print the authorization URL instead of launching the platform browser. Treat the printed URL as sensitive operational output. 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:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"hubspot": {
|
||||
"baseUrl": "https://mcp.hubspot.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauthClientId": "your-client-id",
|
||||
"oauthClientSecretEnv": "HUBSPOT_CLIENT_SECRET",
|
||||
"oauthTokenEndpointAuthMethod": "client_secret_post",
|
||||
"oauthRedirectUrl": "http://127.0.0.1:3434/callback",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Keep client secrets in environment variables or private machine-local configs,
|
||||
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.
|
||||
|
||||
#### Config resolution order & system-level configs
|
||||
@ -406,7 +492,7 @@ mcporter reads exactly one primary config per run. The lookup order is:
|
||||
1. The path you pass via `--config` (or programmatic `configPath`).
|
||||
2. The `MCPORTER_CONFIG` environment variable (set it in your shell to apply everywhere).
|
||||
3. `<root>/config/mcporter.json` inside the current project.
|
||||
4. `~/.mcporter/mcporter.json` or `~/.mcporter/mcporter.jsonc` 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:
|
||||
|
||||
@ -416,6 +502,29 @@ mcporter config --config ~/.mcporter/mcporter.json add global-server https://api
|
||||
|
||||
Set `MCPORTER_CONFIG=~/.mcporter/mcporter.json` in your shell profile when you want that file to be the default everywhere (handy for `npx mcporter …` runs).
|
||||
|
||||
mcporter honors XDG Base Directory env vars for its own files when those vars are explicitly set: `XDG_CONFIG_HOME` for home configs, `XDG_DATA_HOME` for the OAuth vault, `XDG_CACHE_HOME` for schema caches, and `XDG_STATE_HOME` for daemon/runtime state. If the matching XDG var is unset or relative, mcporter keeps the legacy `~/.mcporter` path. 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
|
||||
|
||||
Server definitions can hide or block exact tool names with either `allowedTools` or `blockedTools`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"slack-readonly": {
|
||||
"baseUrl": "https://example.com/slack/mcp",
|
||||
"allowedTools": ["channels_list", "conversations_history"],
|
||||
},
|
||||
"filesystem-safe": {
|
||||
"command": "npx -y @modelcontextprotocol/server-filesystem ~/Downloads",
|
||||
"blockedTools": ["write_file", "delete_file", "move_file"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`allowedTools` is an allowlist: only listed tools appear in `mcporter list` and can be called. An empty array blocks every tool. `blockedTools` is a blocklist: listed tools are hidden and rejected by `mcporter call`. Use exact tool names only, and choose one mode per server.
|
||||
|
||||
## Testing and CI
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
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": {
|
||||
"description": "Chrome DevTools protocol bridge for driving local tabs during debugging or automation.",
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
|
||||
"env": {
|
||||
"npm_config_loglevel": "error"
|
||||
}
|
||||
|
||||
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@ -0,0 +1 @@
|
||||
mcporter.sh
|
||||
@ -67,35 +67,37 @@ Shipping a release means **all** of:
|
||||
17. Tag the release (git tag v<version> && git push --tags).
|
||||
18. Post-tag housekeeping: add a fresh "Unreleased" stub to CHANGELOG.md (set to "- Nothing yet.") and start a new version section for the just-released patch if it isn’t already recorded.
|
||||
|
||||
After the release is live, always update the Homebrew tap and re-verify both installers. That flow should be:
|
||||
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. Uninstall any existing `mcporter` binaries to avoid PATH conflicts:
|
||||
1. Update `steipete/homebrew-tap` -> `Formula/mcporter.rb` with:
|
||||
- URL `https://github.com/openclaw/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
|
||||
- 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.
|
||||
Refresh the tap README highlight so Homebrew users see the new version callout.
|
||||
2. Commit and push the tap update.
|
||||
3. Refresh and reinstall from the real tap:
|
||||
```bash
|
||||
brew uninstall mcporter || true
|
||||
npm uninstall -g mcporter || true
|
||||
brew update
|
||||
brew reinstall steipete/tap/mcporter
|
||||
brew test steipete/tap/mcporter
|
||||
/opt/homebrew/bin/mcporter --version
|
||||
```
|
||||
2. Install from Homebrew, run `brew test` equivalents (`mcporter list --help`), then uninstall so the npm install owns the global `mcporter` binary. If the install fails with a linking conflict (`bin/mcporter already exists`), run `brew link --overwrite mcporter` and rerun the smoke command before uninstalling:
|
||||
4. Run a Homebrew-installed empty-directory compile smoke:
|
||||
```bash
|
||||
brew install steipete/tap/mcporter
|
||||
# If you still have /opt/homebrew/bin/mcporter from npm, fix conflicts with:
|
||||
# brew link --overwrite mcporter
|
||||
mcporter list --help | head -n 5
|
||||
brew uninstall mcporter
|
||||
rm -rf /tmp/mcporter-brew-smoke && mkdir -p /tmp/mcporter-brew-smoke
|
||||
cd /tmp/mcporter-brew-smoke
|
||||
/opt/homebrew/bin/mcporter generate-cli "npx -y chrome-devtools-mcp" --compile
|
||||
./chrome-devtools-mcp --help | head -n 5
|
||||
```
|
||||
3. Install the npm package globally (or leave it to npx) and keep that version in place for day-to-day use:
|
||||
5. Install the npm package globally (or leave it to npx) and verify that path too:
|
||||
```bash
|
||||
npm install -g mcporter@<version>
|
||||
mcporter --version
|
||||
npx --yes mcporter@<version> --version
|
||||
```
|
||||
4. Finally, run a fresh `npx mcporter@<version>` smoke test from an empty temp directory (no runner needed) to ensure the package is usable without global installs.
|
||||
|
||||
5. Update `steipete/homebrew-tap` → `Formula/mcporter.rb` with the new version, tarball URL, and SHA256. Refresh the tap README highlights and changelog snippets so Homebrew users see the new version callouts. (That repo doesn’t include `runner`, so use regular git commands there.)
|
||||
6. Commit and push the tap update.
|
||||
7. Verify the Homebrew flow (after GitHub release assets propagate):
|
||||
6. If installing on another Mac, repeat the real tap reinstall and compile smoke there:
|
||||
```bash
|
||||
brew update
|
||||
brew install steipete/tap/mcporter
|
||||
# If you previously installed mcporter via npm (or another tap) and see a link error,
|
||||
# run `brew link --overwrite mcporter` to replace /opt/homebrew/bin/mcporter with the tap binary.
|
||||
mcporter list --help
|
||||
brew reinstall steipete/tap/mcporter
|
||||
/opt/homebrew/bin/mcporter --version
|
||||
```
|
||||
|
||||
@ -33,7 +33,7 @@ Notice that the second command repeats the URL. Ad-hoc definitions are ephemeral
|
||||
|
||||
## Transport Detection
|
||||
|
||||
- **HTTP(S)**: Providing a URL defaults to the streamable HTTP transport. `https://` works out of the box; `http://` requires `--allow-http` (or the hidden alias `--insecure`) to acknowledge cleartext traffic. The `--sse` flag is a hidden alias for `--http-url` to match older examples.
|
||||
- **HTTP(S)**: Providing a URL defaults to the streamable HTTP transport. `https://` works out of the box; `http://` requires `--allow-http` (or the hidden alias `--insecure`) to acknowledge cleartext traffic. The `--sse` flag is a hidden alias for `--http-url` to match older examples. Use repeatable `--header KEY=value` flags for private servers that require headers such as `Authorization`, `X-API-Key`, or tenant selectors.
|
||||
- **STDIO**: Supplying `--stdio` (with a command string) or `--stdio-bin` (binary + args) selects the stdio transport. Your current shell environment is inherited automatically; use `--env KEY=value` only when you need to inject/override specific variables (and `--cwd` to change directories).
|
||||
- **Conflict guard**: Passing both URL and stdio flags errors out so we don’t guess.
|
||||
|
||||
@ -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:
|
||||
|
||||
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.
|
||||
|
||||
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,7 +62,9 @@ The CLI still avoids surprise prompts during `mcporter list`; the upgrade happen
|
||||
|
||||
- OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions.
|
||||
- `mcporter auth` accepts the same `--http-url/--stdio` flags (and even bare URLs), so you can immediately re-run `mcporter auth https://…` after a 401 without touching a config file.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Safety Nets
|
||||
|
||||
|
||||
60
docs/agent-skills.md
Normal file
60
docs/agent-skills.md
Normal file
@ -0,0 +1,60 @@
|
||||
---
|
||||
summary: 'How to expose mcporter-backed MCP servers to agents through small per-server skills.'
|
||||
read_when:
|
||||
- 'Writing agent skill docs or wiring mcporter into an agent runtime'
|
||||
- 'Triaging requests for a generic mcporter skill'
|
||||
---
|
||||
|
||||
# Agent Skill Pattern
|
||||
|
||||
Prefer one small skill per MCP server or workflow instead of a single generic
|
||||
`mcporter` skill. A focused skill keeps the agent prompt small, names the useful
|
||||
tools directly, and avoids loading schemas for servers that are irrelevant to the
|
||||
current task.
|
||||
|
||||
## Recommended Flow
|
||||
|
||||
1. Add or import the MCP server:
|
||||
|
||||
```bash
|
||||
npx mcporter config add docs https://mcp.context7.com/mcp --scope home
|
||||
```
|
||||
|
||||
2. Inspect the tool surface:
|
||||
|
||||
```bash
|
||||
npx mcporter list docs --brief
|
||||
npx mcporter list docs --schema
|
||||
```
|
||||
|
||||
3. Write a skill that calls only the relevant tools via `mcporter call`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: docs-mcp
|
||||
description: Fetch package and framework docs through the configured docs MCP server.
|
||||
---
|
||||
|
||||
# Docs MCP
|
||||
|
||||
Use `npx mcporter call docs.resolve-library-id query=<task> libraryName=<name>`
|
||||
to resolve a package, then call `npx mcporter call docs.query-docs ...` with
|
||||
the resolved ID and docs query.
|
||||
```
|
||||
|
||||
4. For repeated or shareable workflows, generate a dedicated CLI instead of
|
||||
teaching the agent raw `mcporter call` syntax:
|
||||
|
||||
```bash
|
||||
npx mcporter generate-cli docs --bundle dist/docs-mcp.js
|
||||
```
|
||||
|
||||
## Why Not One Generic Skill?
|
||||
|
||||
A generic skill has to teach the agent how to discover, choose, and call every
|
||||
configured server. That recreates the large-schema context problem MCPorter is
|
||||
trying to avoid. Per-server skills stay small and let the skill author describe
|
||||
the safe, useful workflows for that server.
|
||||
|
||||
Use `allowedTools` or `blockedTools` in `mcporter.json` when a server exposes
|
||||
tools that should not be shown to agents.
|
||||
@ -10,7 +10,7 @@ read_when:
|
||||
|
||||
| Style | Example | Notes |
|
||||
| -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Flag-based (compatible) | `mcporter call linear.create_comment --issue-id LNR-123 --body "Hi"` | Use `key=value`, `key:value`, or `key: value` pairs—ideal for shell scripts. |
|
||||
| Flag-based (compatible) | `mcporter call linear.create_comment --issue-id LNR-123 --body "Hi"` | Use `--key value`, `--key=value`, `key=value`, `key:value`, or `key: value` pairs—ideal for shell scripts. |
|
||||
| Function-call (expressive) | `mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hi")'` | Mirrors the pseudo-TypeScript signature shown by `mcporter list`; unlabeled values map to schema order. |
|
||||
| Structured output | `mcporter call 'linear.create_comment(...)' --output json` | Successful calls emit JSON bodies; failures emit `{ server, tool, issue }` envelopes so automation can react to auth/offline/http errors. |
|
||||
|
||||
@ -40,11 +40,12 @@ Key details:
|
||||
- Run `mcporter list <server> --all-parameters` whenever you want the full signature; the footer repeats `Optional parameters hidden; run with --all-parameters to view all fields.` any time truncation occurs.
|
||||
- Return types come from each tool’s output schema, so you’ll see concrete names when providers include `title` metadata (e.g. `DocumentConnection`). When no schema is advertised we omit the `: Type` suffix entirely instead of showing `unknown`.
|
||||
- Each server concludes with a short `Examples:` block that mirrors the preferred function-call syntax.
|
||||
- Run `mcporter list <server> --brief` (or `--signatures`) when you only want the compact signatures and optional summaries without doc blocks or examples.
|
||||
|
||||
## Function-Call Syntax Details
|
||||
|
||||
- **Named arguments preferred**: `issueId: "123"` keeps calls self-documenting. When labels are omitted, mcporter falls back to positional order defined by the tool schema.
|
||||
- **Optional positional fallback**: omit labels when calling `mcporter 'context7.resolve-library-id("react")'`—arguments map to the schema order after any explicitly named parameters.
|
||||
- **Optional positional fallback**: omit labels when calling `mcporter 'context7.resolve-library-id("React hooks docs", "react")'`—arguments map to the schema order after any explicitly named parameters.
|
||||
- **Literals supported**: strings, numbers, booleans, `null`, arrays, and nested objects. For strings containing spaces or commas, wrap the entire call in single quotes to keep the shell happy.
|
||||
- **Error feedback**: invalid keys, unsupported expressions, or parser failures bubble up with actionable messages (`Unsupported argument expression: Identifier`, `Unable to parse call expression: …`).
|
||||
- **Server selection**: You can embed the server in the expression (`linear.create_comment(...)`) or pass it separately (`--server linear create_comment(...)`).
|
||||
@ -60,18 +61,21 @@ Key details:
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `--args '{ "issueId": "LNR-123" }'` if you already have JSON payloads—nothing changed for that workflow.
|
||||
- Use `--args '{ "issueId": "LNR-123" }'`, `--json '{ "issueId": "LNR-123" }'`, or `--json -` if you already have JSON payloads.
|
||||
- The new syntax respects all existing features (timeouts, `--output`, auto-correction).
|
||||
- Required fields show by default; pass `--all-parameters` when you want the full parameter list (or `--schema` for raw JSON schemas).
|
||||
- When in doubt, run `mcporter list <server>` to see the current signature and sample invocation.
|
||||
|
||||
## Flag-Based Syntax Details
|
||||
|
||||
- `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.
|
||||
- `--key value`, `--key=value`, `key=value`, `key:value`, `key: value`, and `key:=value` all map to the same named-argument handling, so you can type whichever feels most natural for your shell. Long flag keys convert kebab-case to camelCase (`--save-to-drafts true` becomes `saveToDrafts: true`). The `:=` form is accepted as a compatibility alias for `=`.
|
||||
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
|
||||
- `--args -` and `--json -` read a JSON object from stdin.
|
||||
- Named flag-style values can read exact UTF-8 text from a file with `key=@path` or `--key @path`. Paths resolve from the current working directory, file contents remain strings without coercion, and `key=@@literal` produces the literal value `@literal`. Function-call strings such as `body: "@literal"` remain literal.
|
||||
- Bare string values supplied via long flags wrap into one-item arrays when the tool schema declares that field as an array.
|
||||
- Numeric-looking `key=value` arguments are restored to their original string spelling when the tool schema declares that parameter as a string, which keeps timestamp-like IDs such as Slack `thread_ts=1234567890.123456` intact.
|
||||
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
|
||||
- `--no-coerce` disables all coercion for flag-style and positional values (`true`, `null`, and JSON-like values remain strings).
|
||||
- Unknown long flags like `--source` now fail fast instead of silently becoming positional tool arguments. Use `source=value`, `--args '{"source":"value"}'`, or insert `--` before literal positional values that start with `--`.
|
||||
- Long flags without values fail fast. Insert `--` before literal positional values that start with `--`.
|
||||
- `--save-images <dir>` keeps stdout formatting untouched while writing image content blocks to disk when a tool response includes `type: "image"` entries.
|
||||
- `tool=value`/`tool:value` and `server=value` still act as aliases for `--tool` / `--server` when you need to override the selector.
|
||||
|
||||
@ -92,6 +92,7 @@ npx mcporter generate-cli --command "npx -y chrome-devtools-mcp@latest"
|
||||
- When targeting an existing config entry, you can skip `--server` and pass the name as a positional argument:
|
||||
`npx mcporter generate-cli linear --bundle dist/linear.js`.
|
||||
- When the MCP server is a stdio command, you can also skip `--command` by quoting the inline command as the first positional argument (e.g., `npx mcporter generate-cli "npx -y chrome-devtools-mcp@latest"`).
|
||||
- Generated CLIs preserve `lifecycle: "keep-alive"` for embedded stdio servers. At runtime they create a stable generated config under `~/.mcporter/generated/` (or `$XDG_STATE_HOME/mcporter/generated/` when set), auto-start the daemon as needed, and keep the server process alive across separate generated-CLI invocations.
|
||||
- Narrow the CLI to a specific subset of tools with `--include-tools`:
|
||||
`npx mcporter generate-cli linear --include-tools issues_list,issues_create`.
|
||||
- Hide debug or admin tools with `--exclude-tools`:
|
||||
|
||||
@ -14,13 +14,32 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- Without arguments, lists every configured server (with live discovery + brief
|
||||
status).
|
||||
- With a server name, prints TypeScript-style signatures for each tool, doc
|
||||
comments, and optional summaries.
|
||||
comments, optional summaries, and any server `instructions` returned during
|
||||
MCP initialization.
|
||||
- With `server.tool`, prints just that tool; combine with `--schema` for a single
|
||||
tool schema.
|
||||
- Add `--brief` or `--signatures` with a server or `server.tool` target to keep
|
||||
the server header/instructions and print compact signatures without doc
|
||||
comments, examples, or schemas.
|
||||
- Add `--status` with a server target to print only the concise status row
|
||||
instead of full tool docs.
|
||||
- Add `--exit-code` to make the command exit 1 when any checked server is
|
||||
unhealthy, or `--quiet` to suppress output and imply `--exit-code`.
|
||||
- Hidden alias: `list-tools` (kept for muscle memory; not advertised in help output).
|
||||
- Hidden ad-hoc flag aliases: `--sse` for `--http-url`, `--insecure` for `--allow-http` (for plain HTTP testing).
|
||||
- Flags:
|
||||
- `--brief` – compact single-server output; cannot be combined with `--json`,
|
||||
`--schema`, `--verbose`, or `--all-parameters`.
|
||||
- `--signatures` – alias for `--brief`.
|
||||
- `--all-parameters` – include every optional parameter in the signature.
|
||||
- `--schema` – pretty-print the JSON schema for each tool.
|
||||
- `--status` – check server status only; cannot be combined with `--brief`,
|
||||
`--schema`, or `--all-parameters`.
|
||||
- `--exit-code` – exit 1 when any checked server is unhealthy.
|
||||
- `--quiet` – suppress output and exit 1 when any checked server is unhealthy.
|
||||
- `--timeout <ms>` – per-server timeout when enumerating all servers.
|
||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||
tokens only while keeping eligible connections pooled.
|
||||
|
||||
## `mcporter call <server.tool>`
|
||||
|
||||
@ -34,7 +53,45 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- `--save-images <dir>` – persist image content blocks to files under the specified directory.
|
||||
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
||||
- `--no-coerce` – disable all flag-style/positional value coercion.
|
||||
- `key=@path` / `--key @path` – read a named UTF-8 string argument from a file; prefix with `@@` for a literal leading `@`.
|
||||
- `--tail-log` – stream tail output when the tool returns log handles.
|
||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||
tokens only while keeping eligible connections pooled.
|
||||
|
||||
## `mcporter resource <server> [uri]`
|
||||
|
||||
- Lists resources exposed by a server when no URI is provided.
|
||||
- Reads an MCP resource when `uri` is provided and renders text, markdown, JSON,
|
||||
or raw output using the same output formatter as `mcporter call`.
|
||||
- Hidden alias: `resources`.
|
||||
- Useful flags:
|
||||
- `--output auto|text|markdown|json|raw` – choose how to render the response.
|
||||
- `--json` – shortcut for `--output json`.
|
||||
- `--raw` – shortcut for `--output raw`.
|
||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||
tokens only while keeping eligible connections pooled.
|
||||
|
||||
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
|
||||
|
||||
- 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`
|
||||
|
||||
|
||||
113
docs/config.md
113
docs/config.md
@ -58,7 +58,7 @@ mcporter keeps three configuration buckets in sync: repository-scoped JSON (`con
|
||||
The `$schema` property enables IDE autocomplete and validation. Use the raw GitHub URL for the latest schema, or copy `mcporter.schema.json` locally.
|
||||
2. Run `mcporter list linear` (or `mcporter config list linear`) to make sure the runtime can reach it.
|
||||
3. Use `mcporter config add shadcn https://www.shadcn.io/api/mcp` to persist another server without editing JSON.
|
||||
4. Authenticate any OAuth-backed server with either `mcporter auth <name>` or `mcporter config login <name>`; tokens land under `~/.mcporter/<name>/` unless you override `tokenCacheDir`.
|
||||
4. Authenticate any OAuth-backed server with either `mcporter auth <name>` or `mcporter config login <name>`; tokens land in the shared vault (`~/.mcporter/credentials.json`, or `$XDG_DATA_HOME/mcporter/credentials.json` when set) unless you override `tokenCacheDir`.
|
||||
|
||||
## Config Resolution Order
|
||||
|
||||
@ -67,12 +67,23 @@ mcporter now merges home and project config files by default so global servers s
|
||||
1. If you pass `--config <file>` (or set `--config` programmatically), only that file is used—no merging.
|
||||
2. If `MCPORTER_CONFIG` is set, only that file is used—no merging.
|
||||
3. Otherwise, mcporter loads both of these layers (when present):
|
||||
- `~/.mcporter/mcporter.json` or `~/.mcporter/mcporter.jsonc`
|
||||
- `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, falling back to `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists
|
||||
- `<root>/config/mcporter.json`
|
||||
Entries from the project file override entries with the same name from the home file. Each layer still pulls in its own imports before merging.
|
||||
|
||||
All `mcporter config …` mutations still write back to a single file: the explicit path when provided; otherwise the project config path (`<root>/config/mcporter.json`). To edit the home file explicitly, run commands like `mcporter config --config ~/.mcporter/mcporter.json add <name> …` or set `MCPORTER_CONFIG` in your shell profile.
|
||||
|
||||
mcporter honors XDG Base Directory env vars for its own paths when they are explicitly set to absolute paths:
|
||||
|
||||
| Kind | Env var | mcporter path | Legacy fallback |
|
||||
| ------ | ----------------- | ----------------------------------------------- | -------------------- |
|
||||
| config | `XDG_CONFIG_HOME` | `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` | `~/.mcporter/...` |
|
||||
| data | `XDG_DATA_HOME` | `$XDG_DATA_HOME/mcporter/credentials.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` |
|
||||
|
||||
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
|
||||
|
||||
mcporter builds a merged view of all known servers before executing any command. The sources load in this order:
|
||||
@ -93,7 +104,7 @@ Rules:
|
||||
|
||||
`mcporter config` is the entry point for reading and writing configuration files. Use the existing ad-hoc flags on `mcporter list|call|auth` when you want ephemeral definitions; once you’re ready to persist them, switch back to `mcporter config add`.
|
||||
|
||||
Use `--scope home|project` with `mcporter config add` to pick the write target explicitly. `project` is always the default (creating `config/mcporter.json` if needed); `home` writes to `~/.mcporter/mcporter.json` even when a project config is present. `--persist <path>` still takes precedence when you need a custom file.
|
||||
Use `--scope home|project` with `mcporter config add` to pick the write target explicitly. `project` is always the default (creating `config/mcporter.json` if needed); `home` writes to the XDG config path when `XDG_CONFIG_HOME` is set, otherwise `~/.mcporter/mcporter.json`, even when a project config is present. `--persist <path>` still takes precedence when you need a custom file.
|
||||
|
||||
### `mcporter config list [filter]`
|
||||
|
||||
@ -114,6 +125,7 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
|
||||
- `--transport http|sse|stdio`
|
||||
- `--url` or `--command`/`--stdio`
|
||||
- `--env`, `--header`, `--token-cache-dir`, `--description`, `--tag`, `--client-name`, `--oauth-redirect-url`
|
||||
- `--oauth-client-id`, `--oauth-client-secret-env`, `--oauth-token-endpoint-auth-method` for pre-registered OAuth clients.
|
||||
- `--copy-from importKind:name` to clone settings from an imported entry before editing.
|
||||
- `--dry-run` shows the JSON diff without writing, while `--persist <path>` overrides the destination file.
|
||||
|
||||
@ -136,8 +148,9 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
|
||||
### `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`.
|
||||
- `--browser none` suppresses automatic browser launch (useful for copying the URL into a remote browser).
|
||||
- `logout` wipes token caches under `~/.mcporter/<name>/` (or the custom `tokenCacheDir`). Pass `--all` to clear everything.
|
||||
- `--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.
|
||||
|
||||
### `mcporter config doctor`
|
||||
|
||||
@ -145,11 +158,31 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
|
||||
|
||||
## Ad-hoc & Persistence
|
||||
|
||||
- `--http-url` and `--stdio` flags live on `mcporter list|call|auth`, keeping `mcporter config` focused on persistent config files.
|
||||
- `--http-url` and `--stdio` flags live on `mcporter list|call|auth`, keeping `mcporter config` focused on persistent config files. Ad-hoc HTTP targets also accept repeatable `--header KEY=value` flags for private endpoints.
|
||||
- Names default to slugified hostnames or executable/script combos. Supply `--name` to improve reuse; mcporter uses that slug for OAuth caches even before persistence.
|
||||
- `--allow-http` is mandatory for cleartext endpoints so we never downgrade transport silently.
|
||||
- Add `--persist <path>` (defaulting to `config/mcporter.json` when omitted) to copy the ad-hoc definition into config. We reuse the same serializer as the import pipeline, so copying from Cursor → local config produces identical structure and preserves custom env/header fields.
|
||||
- When an ad-hoc HTTP server returns an OAuth challenge during `list`, `call`, or `auth`, the persisted entry is rewritten with `auth: "oauth"` so later commands use the cached OAuth path instead of retrying unauthenticated HTTP.
|
||||
- `--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.
|
||||
|
||||
## 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
|
||||
|
||||
@ -184,21 +217,57 @@ Top-level structure:
|
||||
|
||||
Server definition fields (subset of what `RawEntrySchema` accepts):
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `description` | Free-form summary printed by `mcporter list`/`config list`. |
|
||||
| `baseUrl` / `url` / `serverUrl` | HTTPS or HTTP endpoint. `http://` requires `--allow-http` in ad-hoc mode but works in config if you explicitly set it. |
|
||||
| `command` / `args` | Stdio executable definition (string or array). Arrays are preferred because they avoid shell quoting issues. |
|
||||
| `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. |
|
||||
| `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. |
|
||||
| `tokenCacheDir` | Directory for OAuth tokens; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` (legacy per-server caches are auto-migrated). Supports `~` expansion. |
|
||||
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
|
||||
| `oauthRedirectUrl` | Override the default localhost callback. 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`. |
|
||||
| `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. |
|
||||
| Field | Description |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `description` | Free-form summary printed by `mcporter list`/`config list`. |
|
||||
| `baseUrl` / `url` / `serverUrl` | HTTPS or HTTP endpoint. `http://` requires `--allow-http` in ad-hoc mode but works in config if you explicitly set it. |
|
||||
| `command` / `args` | Stdio executable definition (string or array). Arrays are preferred because they avoid shell quoting issues. |
|
||||
| `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. |
|
||||
| `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` | 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. |
|
||||
| `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. |
|
||||
| `oauthClientSecretEnv` | Environment variable containing the OAuth client secret. Prefer this over committing `oauthClientSecret` directly. |
|
||||
| `oauthTokenEndpointAuthMethod` | Optional token endpoint auth method override, for example `client_secret_post` when the provider requires client credentials in the token request body. |
|
||||
| `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`. |
|
||||
| `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`. |
|
||||
| `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.
|
||||
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
|
||||
|
||||
@ -209,8 +278,10 @@ mcporter normalizes headers to include `Accept: application/json, text/event-str
|
||||
## Project vs. Machine Layers
|
||||
|
||||
- Keep `config/mcporter.json` under version control. Encourage contributors to add sensitive data via env vars (`${LINEAR_API_KEY}`) rather than inline secrets.
|
||||
- Machine-specific additions can live in `~/.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/<name>/`, `dist/`).
|
||||
- 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.
|
||||
- OAuth tokens, cached server metadata, and generated CLIs should remain outside the repo (`~/.mcporter/...` or the matching `XDG_*_HOME/mcporter/...`, plus `dist/`).
|
||||
|
||||
## Validation & Troubleshooting
|
||||
|
||||
|
||||
@ -8,9 +8,9 @@ read_when:
|
||||
|
||||
## Goals
|
||||
|
||||
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools`). No extra flags for agents.
|
||||
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools` or CloudBase device authentication). No extra flags for agents.
|
||||
- **Shared state:** Multiple CLI invocations/agents within the same user session must reuse the same warm transport so STDIO servers can hold tabs, cookies, and other stateful context.
|
||||
- **Per-login scope:** The daemon lives under the current user account (`~/.mcporter/daemon.sock`) 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.
|
||||
- **Explicit shutdown:** Provide `mcporter daemon stop` to tear everything down (plus `status` for debugging).
|
||||
- **Configurable participation:** Only servers marked keep-alive participate; others keep current ephemeral behavior. Support opt-in/out via config/env plus a default allowlist.
|
||||
@ -34,11 +34,11 @@ read_when:
|
||||
- **Keep-alive detection:**
|
||||
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
|
||||
- Provide a config-level `defaultKeepAlive` array or `MCPORTER_KEEPALIVE` env var for quick overrides.
|
||||
- Ship a hardcoded allowlist (initially `chrome-devtools`, `mobile-mcp`, `playwright`) so existing configs benefit immediately; users can opt out per server.
|
||||
- Ship a hardcoded allowlist (`chrome-devtools`, `mobile-mcp`, `playwright`, `cloudbase`) so existing configs benefit immediately; users can opt out per server.
|
||||
|
||||
## CLI Surface
|
||||
|
||||
- `mcporter daemon start [--foreground]`: boot the daemon; default behavior is background (detached) launch that writes its metadata file (`~/.mcporter/daemon.json` with PID/socket).
|
||||
- `mcporter daemon start [--foreground]`: boot the daemon; default behavior is background (detached) launch that writes its metadata file under the daemon runtime directory.
|
||||
- `mcporter daemon status`: show whether the daemon is running, the socket path, uptime, and which servers are currently connected/idle.
|
||||
- `mcporter daemon stop`: instruct the daemon to close all transports and remove its socket/metadata; if the daemon is missing, exit 0 with a hint.
|
||||
- `mcporter daemon restart`: convenience wrapper that stops the daemon (if it exists), waits for the socket to disappear, and launches a fresh instance while reusing the same logging flags/env overrides.
|
||||
@ -47,9 +47,21 @@ read_when:
|
||||
## Lifecycle & Fault Handling
|
||||
|
||||
- **Auto start:** First call requiring the daemon triggers a lightweight bootstrap (fork/exec via `child_process.spawn` inside the CLI). We ensure the original command waits for the socket to become available (with a short timeout).
|
||||
- **macOS Bun binaries:** Homebrew/Bun-compiled binaries wrap the detached child launch with `nohup` so the background daemon survives the parent CLI exit on macOS 26.
|
||||
- **Auto restart:** The client shim treats `ECONNREFUSED`/broken pipe as a signal that the daemon died. It retries once by re-launching the daemon before surfacing the error.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A global `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Logging:** Daemon writes structured logs under `~/.mcporter/logs/daemon.log` plus per-server logs for STDIO stderr so users can debug crashing servers.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A top-level config `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Logging:** Daemon writes structured logs under the daemon runtime directory plus per-server logs for STDIO stderr so users can debug crashing servers.
|
||||
|
||||
## Agent Isolation
|
||||
|
||||
By default, multiple agents using the same config path share the same keep-alive daemon. That is deliberate: stateful servers such as browser or device MCPs can keep tabs, sessions, and subprocesses warm across repeated CLI calls.
|
||||
|
||||
If each agent needs independent MCP state, give each agent either:
|
||||
|
||||
- a distinct `--config <path>` / `MCPORTER_CONFIG` value, which produces a distinct daemon socket and metadata file; or
|
||||
- a distinct `MCPORTER_DAEMON_DIR`, which isolates the whole daemon runtime directory even when the config path is shared. This explicit override wins over `XDG_STATE_HOME`.
|
||||
|
||||
Non-keep-alive servers remain process-local and do not use the daemon.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
@ -74,7 +86,7 @@ read_when:
|
||||
|
||||
You can capture the daemon’s stdout/stderr (and per-server call traces) when debugging long-lived STDIO servers:
|
||||
|
||||
- `mcporter daemon start --log` enables logging with the default path `~/.mcporter/daemon/daemon-<config-hash>.log`. Use `--log-file <path>` to override it.
|
||||
- `mcporter daemon start --log` enables logging with the default path `~/.mcporter/daemon/daemon-<config-hash>.log`, or `$XDG_STATE_HOME/mcporter/daemon/daemon-<config-hash>.log` when `XDG_STATE_HOME` is set. Use `--log-file <path>` to override it.
|
||||
- `--log-servers chrome-devtools,mobile-mcp` restricts per-call logging to the listed servers. Without it, `--log` records every keep-alive server’s activity.
|
||||
- Environment equivalents:
|
||||
- `MCPORTER_DAEMON_LOG=1` – enable logging.
|
||||
@ -90,6 +102,6 @@ Logs include timestamped entries such as:
|
||||
[daemon] 2025-11-10T15:08:22.004Z callTool success server=chrome-devtools tool=take_snapshot
|
||||
```
|
||||
|
||||
Tailing the file (`tail -f ~/.mcporter/daemon/daemon-*.log`) surfaces crashes or repeated failures without needing to re-run the daemon in the foreground.
|
||||
Tailing the file (`tail -f ~/.mcporter/daemon/daemon-*.log`, or the matching XDG state path) surfaces crashes or repeated failures without needing to re-run the daemon in the foreground.
|
||||
|
||||
Once these steps land, agents can freely use persistent MCP servers without juggling multiple Chrome launches, while still retaining an explicit shutdown path.
|
||||
|
||||
@ -27,7 +27,7 @@ culprit is a child MCP server process that keeps the stdio transport alive.
|
||||
gathering diagnostics.
|
||||
6. **Clamp OAuth waits** – when the browser-based sign-in never completes,
|
||||
run with `--oauth-timeout <ms>` (or `MCPORTER_OAUTH_TIMEOUT_MS`) so the CLI
|
||||
tears down the pending flow instead of waiting the full minute.
|
||||
tears down the pending flow instead of waiting the full 5 minutes.
|
||||
|
||||
## Example Session
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ Set `"imports": []` when you want to disable auto-merging entirely, or supply a
|
||||
- `{ "mcpServers": { ... } }` (Cursor-style).
|
||||
- `{ "servers": { ... } }` (older VS Code previews).
|
||||
- **TOML container**: Codex uses TOML files with `[mcp_servers.<name>]` tables. Only `.codex/config.toml` is recognized.
|
||||
- **Shared fields**: We convert JSON/TOML entries into mcporter’s schema, honoring `baseUrl`, `command` (string or array), `args`, `headers`, `env`, `bearerToken`, `bearerTokenEnv`, `description`, `tokenCacheDir`, `clientName`, and `auth`. Extra properties are ignored.
|
||||
- **Shared fields**: We convert JSON/TOML entries into mcporter’s schema, honoring `baseUrl`, `command` (string or array), `args`, `headers`, `env`, `bearerToken`, `bearerTokenEnv`, `description`, `tokenCacheDir`, `clientName`, `oauthClientId`, `oauthClientSecretEnv`, `oauthTokenEndpointAuthMethod`, and `auth`. Extra properties are ignored.
|
||||
|
||||
## Import Support Matrix
|
||||
|
||||
|
||||
70
docs/index.md
Normal file
70
docs/index.md
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Overview
|
||||
permalink: /
|
||||
summary: 'Overview of mcporter as a portable MCP runtime, CLI, generated-CLI toolkit, and typed-client layer.'
|
||||
description: 'mcporter is a TypeScript runtime, CLI, and code-generation toolkit for the Model Context Protocol — built so AI agents and developers can call any MCP server without boilerplate.'
|
||||
---
|
||||
|
||||
## Try it
|
||||
|
||||
mcporter auto-discovers the MCP servers already configured in Cursor, Claude Code/Desktop, Codex, Windsurf, OpenCode, and VS Code. Try it without installing anything:
|
||||
|
||||
```bash
|
||||
# List every MCP server you already have configured.
|
||||
npx mcporter list
|
||||
|
||||
# Inspect a single server with TypeScript-style signatures.
|
||||
npx mcporter list linear --schema
|
||||
|
||||
# Call a tool — colon flags, function-call syntax, or trailing positional values.
|
||||
npx mcporter call linear.create_comment issueId:ENG-123 body:'Looks good!'
|
||||
npx mcporter call 'linear.create_comment(issueId: "ENG-123", body: "Looks good!")'
|
||||
|
||||
# Read or list MCP resources.
|
||||
npx mcporter resource docs
|
||||
npx mcporter resource docs file:///path/to/spec.md
|
||||
|
||||
# Mint a standalone CLI for any MCP server, ready to ship.
|
||||
npx mcporter generate-cli linear --bundle dist/linear.js
|
||||
|
||||
# Emit `.d.ts` types or a typed client for agents and tests.
|
||||
npx mcporter emit-ts linear --mode client --out src/linear-client.ts
|
||||
```
|
||||
|
||||
`--json` produces a stable JSON envelope on stdout; human progress, prompts, and warnings always go to stderr so pipes stay parseable.
|
||||
|
||||
## What mcporter does
|
||||
|
||||
mcporter leans into the **code-execution-with-MCP** pattern Anthropic recommends: skip the giant tool-schema prompt, generate a small typed surface, and let the agent or the human call MCP servers like normal functions.
|
||||
|
||||
- **Zero-config discovery.** Reads your home config (`~/.mcporter/mcporter.json[c]`, or `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]`), then `config/mcporter.json`, then imports from Cursor / Claude / Codex / Windsurf / OpenCode / VS Code. `${ENV}` placeholders are expanded; transports are pooled across calls.
|
||||
- **One-command CLI generation.** [`mcporter generate-cli`](cli-generator.md) turns any MCP server into a ready-to-run CLI with embedded schemas, optional Rolldown/Bun bundling, and Bun-compiled binaries.
|
||||
- **Typed clients.** [`mcporter emit-ts`](emit-ts.md) emits `.d.ts` interfaces or a ready-to-run client wrapping `createServerProxy()` so agents call MCP tools with full TypeScript types.
|
||||
- **Friendly composable API.** [`createServerProxy()`](tool-calling.md) maps tools to camelCase methods, applies JSON-schema defaults, validates required arguments, and returns a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()` helpers.
|
||||
- **Ad-hoc connections + auto-OAuth.** Point the CLI at any MCP endpoint (HTTP, SSE, stdio) without touching config. Hosted MCPs that need a browser login (Supabase, Vercel, etc.) are auto-detected — `mcporter auth <url>` promotes the definition to OAuth on the fly. See [Ad-hoc connections](adhoc.md).
|
||||
- **MCP bridge for agents.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP server with namespaced `server__tool` tools, or as per-server HTTP paths that keep original tool names.
|
||||
- **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
|
||||
|
||||
mcporter is designed to be the layer between an MCP server and a coding agent. The pattern we recommend:
|
||||
|
||||
1. Configure the server once (or import from your editor of choice).
|
||||
2. Run [`mcporter emit-ts <server>`](emit-ts.md) to get a `.d.ts` of the tool surface.
|
||||
3. Wire small per-server [agent skills](agent-skills.md) instead of one mega-schema prompt — small prompts, named tools, no unrelated schemas loaded.
|
||||
4. For shareable workflows, generate a standalone CLI with [`mcporter generate-cli`](cli-generator.md).
|
||||
|
||||
Because every transport flows through the same runtime, an agent that knows how to spawn `mcporter call` works with stdio servers, hosted HTTP MCPs, OAuth-gated services, and one-off URLs alike.
|
||||
|
||||
## Why a porter?
|
||||
|
||||
A _porter_ carries luggage between trains. mcporter does the same for MCP servers: it carries tool calls, schemas, OAuth tokens, and stdio handles between your agent (or your terminal) and whichever MCP server happens to be at the other end of the line. You don't have to know the shape of the server ahead of time, and the runtime keeps the connection warm so repeat calls are cheap.
|
||||
|
||||
## Where to next
|
||||
|
||||
- [Install](install.md) — npm, npx, Homebrew, or the standalone Bun-compiled binary.
|
||||
- [Quickstart](quickstart.md) — your first list/call/resource in five minutes.
|
||||
- [Configuration](config.md) — `mcporter.json`, imports, env interpolation, OAuth.
|
||||
- [CLI reference](cli-reference.md) — every subcommand and flag.
|
||||
- [Ad-hoc connections](adhoc.md) — point at any MCP endpoint without editing config.
|
||||
- [Agent skills](agent-skills.md) — exposing servers to agents the right way.
|
||||
68
docs/install.md
Normal file
68
docs/install.md
Normal file
@ -0,0 +1,68 @@
|
||||
---
|
||||
summary: 'How to install mcporter — npx, npm, pnpm, Homebrew, or a standalone Bun-compiled binary.'
|
||||
---
|
||||
|
||||
# Install
|
||||
|
||||
mcporter ships as both a published npm package and a Homebrew formula. Most workflows can also run mcporter without installing anything via `npx`.
|
||||
|
||||
## Try without installing
|
||||
|
||||
```bash
|
||||
npx mcporter --version
|
||||
npx mcporter list
|
||||
```
|
||||
|
||||
`npx` keeps the package in your npm cache, so subsequent runs are instant. This is the recommended first step.
|
||||
|
||||
## npm / pnpm / Bun
|
||||
|
||||
Install globally:
|
||||
|
||||
```bash
|
||||
npm install -g mcporter
|
||||
```
|
||||
|
||||
Or add it to a project:
|
||||
|
||||
```bash
|
||||
pnpm add mcporter # or: npm install mcporter / bun add mcporter
|
||||
```
|
||||
|
||||
mcporter targets Node 24+ and works under Bun. The package exposes both an importable runtime (`createRuntime`, `callOnce`, `createServerProxy`) and the `mcporter` CLI binary.
|
||||
|
||||
## Homebrew
|
||||
|
||||
```bash
|
||||
brew install steipete/tap/mcporter
|
||||
```
|
||||
|
||||
The tap publishes alongside npm. If you previously installed from an older tap, run `brew update` before reinstalling so Homebrew picks up the new formula path.
|
||||
|
||||
## Standalone binary
|
||||
|
||||
Each release also ships a Bun-compiled standalone binary you can drop on `$PATH` without a Node toolchain. Grab the asset for your OS/arch from the [GitHub releases page](https://github.com/steipete/mcporter/releases) and `chmod +x` it. The compiled CLI behaves the same as the Node build but boots noticeably faster and bundles its dependencies.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
mcporter --version
|
||||
mcporter list
|
||||
```
|
||||
|
||||
The first invocation will print every MCP server it discovered across your configs (Cursor, Claude Code/Desktop, Codex, Windsurf, OpenCode, VS Code). If nothing shows up, jump to [Configuration](config.md) to add a server.
|
||||
|
||||
## Updating
|
||||
|
||||
- `npm`: `npm install -g mcporter@latest`
|
||||
- `pnpm`: `pnpm up -g mcporter@latest`
|
||||
- `brew`: `brew upgrade steipete/tap/mcporter`
|
||||
- Standalone binary: download a fresh release asset.
|
||||
|
||||
## Uninstall
|
||||
|
||||
- `npm uninstall -g mcporter`
|
||||
- `brew uninstall steipete/tap/mcporter`
|
||||
- Standalone binary: delete the file you copied onto `$PATH`.
|
||||
|
||||
mcporter stores OAuth tokens and cached schemas under `~/.mcporter/` (or `$XDG_CACHE_HOME/mcporter/` when set). Remove that directory if you want a fully clean slate.
|
||||
@ -14,7 +14,9 @@ This file tracks limitations that users regularly run into. Most of these requir
|
||||
- Use Supabase’s supported clients (Cursor, Windsurf).
|
||||
- Self-host their MCP server and configure PAT headers / custom OAuth.
|
||||
- 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. Until GitHub publishes a dynamic-registration API (or client secrets), mcporter cannot interact with their hosted server.
|
||||
- GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilot’s backend expects pre-registered client credentials. Configure `oauthClientId`/`oauthClientSecretEnv` only if the provider gives you a usable OAuth app; otherwise use their supported client or token/header workaround.
|
||||
- Some hosted servers reject dynamic client registration before returning any authorization URL. mcporter now fails those flows immediately instead of waiting for a browser callback that cannot arrive. If the provider supports a pre-registered OAuth app, configure `oauthClientId`, `oauthClientSecretEnv`, and the required `oauthTokenEndpointAuthMethod`; otherwise use the provider's supported client or token/header workaround.
|
||||
- `mcporter auth <server> --no-browser` still starts a loopback callback server and must stay alive until the browser redirects back. Process managers that run commands in short-lived process groups can print the authorization URL and then reap the process tree, leaving no listener on the callback port and no saved tokens. Run headless OAuth from a persistent terminal, `tmux`, or `nohup`/a supervisor, and use a configured `oauthRedirectUrl` or loopback tunnel when the browser runs elsewhere.
|
||||
|
||||
## Output schemas missing/buggy on many servers
|
||||
|
||||
|
||||
@ -20,10 +20,10 @@ pnpm exec tsx src/cli.ts list
|
||||
pnpm exec tsx src/cli.ts list --json
|
||||
|
||||
# call a tool (auto formatted)
|
||||
pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react
|
||||
pnpm exec tsx src/cli.ts call context7.resolve-library-id query="React hooks docs" libraryName=react
|
||||
|
||||
# call a tool but emit structured JSON on success/failure
|
||||
pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react --output json
|
||||
pnpm exec tsx src/cli.ts call context7.resolve-library-id query="React hooks docs" libraryName=react --output json
|
||||
|
||||
# auth flow
|
||||
pnpm exec tsx src/cli.ts auth vercel
|
||||
@ -60,7 +60,7 @@ After `pnpm add mcporter` in your project (or inside this repo), the shim binari
|
||||
|
||||
```bash
|
||||
pnpm mcporter:list
|
||||
pnpm mcporter:call context7.get-library-docs topic=hooks
|
||||
pnpm mcporter:call context7.query-docs libraryId=/reactjs/react.dev query=hooks
|
||||
```
|
||||
|
||||
## Debug flags recap
|
||||
|
||||
@ -12,7 +12,7 @@ The keep-alive daemon can tee its stdout/stderr (and per-server call traces) int
|
||||
|
||||
### CLI flags
|
||||
|
||||
- `mcporter daemon start --log` — enable logging at the default path `~/.mcporter/daemon/daemon-<config-hash>.log`.
|
||||
- `mcporter daemon start --log` — enable logging at the default path `~/.mcporter/daemon/daemon-<config-hash>.log`, or `$XDG_STATE_HOME/mcporter/daemon/daemon-<config-hash>.log` when `XDG_STATE_HOME` is set.
|
||||
- `mcporter daemon start --log-file /tmp/mcporter-daemon.log` — write logs to a specific file (path is created if needed).
|
||||
- `mcporter daemon start --log-servers chrome-devtools,mobile-mcp` — only emit per-call entries for the listed servers. Without this flag, `--log` records every keep-alive server’s calls.
|
||||
|
||||
@ -38,7 +38,7 @@ Add a logging block inside the server definition (alongside `lifecycle`) when yo
|
||||
"chrome-devtools": {
|
||||
"description": "Chrome DevTools protocol bridge",
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
|
||||
"lifecycle": "keep-alive",
|
||||
"logging": {
|
||||
"daemon": { "enabled": true }
|
||||
@ -60,7 +60,7 @@ When combined with `--log`/`MCPORTER_DAEMON_LOG=1`, any server that has `logging
|
||||
|
||||
### Defaults & cleanup
|
||||
|
||||
Log files live under `~/.mcporter/daemon/` next to the socket/metadata. They’re not rotated automatically yet; delete/rotate them manually if they grow large. Running `mcporter daemon stop` leaves the log intact so you can inspect it after a crash.
|
||||
Log files live under `~/.mcporter/daemon/` next to the socket/metadata, or under `$XDG_STATE_HOME/mcporter/daemon/` when `XDG_STATE_HOME` is set. They’re not rotated automatically yet; delete/rotate them manually if they grow large. Running `mcporter daemon stop` leaves the log intact so you can inspect it after a crash.
|
||||
|
||||
## Foreground debugging
|
||||
|
||||
|
||||
@ -74,6 +74,13 @@ Expectations:
|
||||
- If a token cache exists, log should mention the cleared directory.
|
||||
- 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
|
||||
|
||||
- To exercise error paths, point at a placeholder endpoint and use `--timeout 1000` (e.g., `https://example.com/mcp.listStuff`).
|
||||
|
||||
@ -18,6 +18,8 @@ mcporter is the Sweetistics CLI + runtime for the Model Context Protocol (MCP).
|
||||
Lists tool metadata, renders TypeScript-style signatures, and surfaces copy/pasteable examples (including ad-hoc HTTP selectors).
|
||||
- `npx mcporter call server.tool key=value …`
|
||||
Invokes a tool via either flag syntax or the function-call expression form; add `--output json` to capture structured responses.
|
||||
- `npx mcporter resource server [uri]`
|
||||
Lists MCP resources for a server, or reads a specific resource URI and renders text/markdown/JSON/raw output.
|
||||
- `npx mcporter generate-cli --server name [--bundle|--compile]`
|
||||
Emits a standalone CLI for a single MCP server. Bundling defaults to Rolldown unless the runtime resolves to Bun; compiled binaries require Bun.
|
||||
- `npx mcporter emit-ts <server> --mode types|client`
|
||||
@ -33,7 +35,7 @@ Use `createServerProxy(runtime, name)` inside scripts when you want ergonomic ca
|
||||
2. Automatically merges default values.
|
||||
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
|
||||
|
||||
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control.
|
||||
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control. Headless callers that must rely on cached tokens without launching OAuth can pass `disableOAuth: true` to `connect`, `callTool`, `listTools`, resource helpers, and `callOnce`; this suppresses interactive OAuth while keeping eligible connections pooled.
|
||||
|
||||
## Debug + Support Docs
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ npm install mcporter
|
||||
|
||||
## 3. OAuth Tokens
|
||||
|
||||
- Tokens are saved under `~/.mcporter/<server>/` by default.
|
||||
- Tokens are saved in the shared vault under `~/.mcporter/credentials.json` by default, or `$XDG_DATA_HOME/mcporter/credentials.json` when `XDG_DATA_HOME` is set.
|
||||
- To force a fresh login, delete that directory and rerun the command; the CLI will relaunch the browser.
|
||||
- Custom `token_cache_dir` entries in `mcporter.json` continue to work as explicit overrides.
|
||||
|
||||
@ -65,12 +65,12 @@ Use `callOnce` for fire-and-forget invocations.
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
| Browser did not open | Copy the printed OAuth URL manually into a browser. |
|
||||
| Authorization hangs | Ensure the callback URL can bind to `127.0.0.1`; firewalls may block it. |
|
||||
| Tokens are stale | Delete `~/.mcporter/<server>/tokens.json` and retry. |
|
||||
| Stdio command fails | Pass `--root` to point at the repo root so relative paths resolve. |
|
||||
| Symptom | Fix |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Browser did not open | Copy the printed OAuth URL manually into a browser. |
|
||||
| Authorization hangs | Ensure the callback URL can bind to `127.0.0.1`; firewalls may block it. |
|
||||
| Tokens are stale | Run `mcporter auth --reset <server>` or delete the matching vault entry/cache and retry. |
|
||||
| Stdio command fails | Pass `--root` to point at the repo root so relative paths resolve. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
80
docs/quickstart.md
Normal file
80
docs/quickstart.md
Normal file
@ -0,0 +1,80 @@
|
||||
---
|
||||
summary: 'Five-minute walk through listing MCP servers, calling a tool, and emitting a typed client.'
|
||||
---
|
||||
|
||||
# Quickstart
|
||||
|
||||
This walkthrough assumes you already have an MCP server configured in Cursor, Claude Code/Desktop, Codex, Windsurf, OpenCode, or VS Code. If not, copy [`config/mcporter.example.json`](https://github.com/steipete/mcporter/blob/main/config/mcporter.example.json) into `~/.mcporter/mcporter.json` and edit it — see [Configuration](config.md) for the full schema.
|
||||
|
||||
## 1. List the servers mcporter sees
|
||||
|
||||
```bash
|
||||
npx mcporter list
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
npx mcporter list linear
|
||||
```
|
||||
|
||||
Single-server output reads like a TypeScript header file: dimmed `/** … */` doc comments above each `function name(...)` signature, with optional parameters summarised so the screen stays scannable. Add flags to drill in:
|
||||
|
||||
- `--brief` (alias `--signatures`) — compact signatures only.
|
||||
- `--all-parameters` — show every optional parameter inline.
|
||||
- `--schema` — pretty-print the JSON schema for each tool.
|
||||
- `--json` — machine-readable schema payload.
|
||||
- `--status` — concise status only, without tool docs.
|
||||
|
||||
`mcporter list shadcn.io/api/mcp.getComponents` works too — bare URLs (with or without a `.tool` suffix or scheme) auto-resolve.
|
||||
|
||||
## 3. Call a tool
|
||||
|
||||
```bash
|
||||
# Colon-delimited flags (shell-friendly).
|
||||
npx mcporter call linear.create_comment issueId:ENG-123 body:'Looks good!'
|
||||
|
||||
# Function-call style copy/pasted from `mcporter list`.
|
||||
npx mcporter call 'linear.create_comment(issueId: "ENG-123", body: "Looks good!")'
|
||||
|
||||
# Anything after `--` is a literal positional value.
|
||||
npx mcporter call docs.fetch -- --raw-string-with-leading-dashes
|
||||
```
|
||||
|
||||
Pick the output format with `--output text|markdown|json|raw`. Use `--save-images <dir>` to persist binary content blocks. See [CLI reference](cli-reference.md) for the full flag list.
|
||||
|
||||
## 4. Read MCP resources
|
||||
|
||||
```bash
|
||||
npx mcporter resource docs # list resources
|
||||
npx mcporter resource docs file:///path/to/spec.md # read a resource
|
||||
```
|
||||
|
||||
Output formatting is shared with `mcporter call` (`--output`, `--json`, `--raw`).
|
||||
|
||||
## 5. Generate a standalone CLI
|
||||
|
||||
When you want to share a tool with someone who shouldn't have to learn `mcporter call`:
|
||||
|
||||
```bash
|
||||
npx mcporter generate-cli linear --bundle dist/linear.js
|
||||
node dist/linear.js create-comment --issue-id ENG-123 --body 'Looks good!'
|
||||
```
|
||||
|
||||
Add `--compile <path>` for a Bun-compiled binary, or `--include-tools a,b,c` to ship a subset. Full details in [CLI generator](cli-generator.md).
|
||||
|
||||
## 6. Emit typed clients for agents
|
||||
|
||||
```bash
|
||||
npx mcporter emit-ts linear --mode client --out src/linear-client.ts
|
||||
```
|
||||
|
||||
You get a `.d.ts` interface and a `createServerProxy()`-backed factory. Calls return `CallResult` objects with `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()` helpers — see [Tool calling](tool-calling.md) for the proxy API and [emit-ts](emit-ts.md) for the generator.
|
||||
|
||||
## What next
|
||||
|
||||
- [Configuration](config.md) — `mcporter.json` schema, env interpolation, OAuth fields.
|
||||
- [Ad-hoc connections](adhoc.md) — point at any MCP endpoint without editing config.
|
||||
- [Agent skills](agent-skills.md) — wiring per-server skills into a coding agent.
|
||||
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.
|
||||
BIN
docs/social-card.png
Normal file
BIN
docs/social-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
129
docs/social-card.svg
Normal file
129
docs/social-card.svg
Normal file
@ -0,0 +1,129 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">mcporter social card</title>
|
||||
<desc id="desc">mcporter: MCP, made portable. TypeScript runtime, CLI, and code-generation toolkit for the Model Context Protocol.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0a0c12"/>
|
||||
<stop offset="0.55" stop-color="#10131c"/>
|
||||
<stop offset="1" stop-color="#0c0f17"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandSweep" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#7c3aed"/>
|
||||
<stop offset="0.45" stop-color="#06b6d4"/>
|
||||
<stop offset="0.85" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c3aed"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0e121b"/>
|
||||
<stop offset="1" stop-color="#070a10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panelBar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1a1f2c"/>
|
||||
<stop offset="1" stop-color="#0f131c"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="haze" cx="0.18" cy="0.22" r="0.6">
|
||||
<stop offset="0" stop-color="#7c3aed" stop-opacity="0.28"/>
|
||||
<stop offset="0.6" stop-color="#06b6d4" stop-opacity="0.08"/>
|
||||
<stop offset="1" stop-color="#06b6d4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="22" stdDeviation="34" flood-color="#000000" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect width="1200" height="630" fill="url(#haze)"/>
|
||||
|
||||
<!-- Top accent line -->
|
||||
<rect x="0" y="0" width="1200" height="6" fill="url(#brandSweep)"/>
|
||||
|
||||
<!-- Brand mark: stylized suitcase icon -->
|
||||
<g transform="translate(76 76)">
|
||||
<rect x="0" y="0" width="118" height="118" rx="24" fill="url(#logoGrad)"/>
|
||||
<rect x="22" y="42" width="74" height="56" rx="8" fill="#0a0c12"/>
|
||||
<rect x="40" y="28" width="38" height="16" rx="4" fill="#0a0c12"/>
|
||||
<rect x="22" y="58" width="74" height="3" fill="rgba(255,255,255,0.18)"/>
|
||||
<circle cx="59" cy="74" r="6" fill="#a78bfa"/>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="76" y="276" fill="#f5f7fb" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="100" font-weight="800" letter-spacing="-1">mcporter</text>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="80" y="338" fill="#cbd5e1" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="34" font-weight="700" letter-spacing="0">MCP, made portable.</text>
|
||||
|
||||
<!-- Description -->
|
||||
<text x="80" y="386" fill="#94a3b8" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500">TypeScript runtime + CLI for the Model Context Protocol.</text>
|
||||
<text x="80" y="414" fill="#94a3b8" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500">Discover, call, and generate clients for any MCP server.</text>
|
||||
|
||||
<!-- Multi-color accent bar -->
|
||||
<rect x="80" y="440" width="280" height="4" rx="2" fill="url(#brandSweep)"/>
|
||||
|
||||
<!-- Bottom row: install pill + URL pill -->
|
||||
<g transform="translate(80 478)">
|
||||
<rect x="0" y="0" width="320" height="48" rx="11" fill="#0e121b" stroke="#1f2937"/>
|
||||
<text x="20" y="32" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="500">$</text>
|
||||
<text x="42" y="32" fill="#e6edf3" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">npx mcporter list</text>
|
||||
|
||||
<rect x="340" y="0" width="180" height="48" rx="11" fill="#0e121b" stroke="#1f2937"/>
|
||||
<text x="362" y="32" fill="#a78bfa" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">mcporter.sh</text>
|
||||
</g>
|
||||
|
||||
<!-- Right-side terminal mockup -->
|
||||
<g transform="translate(670 142)" filter="url(#shadow)">
|
||||
<rect x="0" y="0" width="464" height="346" rx="20" fill="url(#panel)" stroke="#1b2030"/>
|
||||
<rect x="0" y="0" width="464" height="42" rx="20" fill="url(#panelBar)"/>
|
||||
<rect x="0" y="22" width="464" height="20" fill="url(#panelBar)"/>
|
||||
<circle cx="24" cy="21" r="6" fill="#ec4899"/>
|
||||
<circle cx="46" cy="21" r="6" fill="#f59e0b"/>
|
||||
<circle cx="68" cy="21" r="6" fill="#10b981"/>
|
||||
<text x="232" y="27" fill="#94a3b8" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="500" text-anchor="middle">mcporter — agent ready</text>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<text x="22" y="80" fill="#a78bfa" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ npx mcporter list linear</text>
|
||||
<text x="22" y="108" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">/**</text>
|
||||
<text x="22" y="126" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13"> * Create a comment on a Linear issue</text>
|
||||
<text x="22" y="144" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13"> * @param issueId The issue ID</text>
|
||||
<text x="22" y="162" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13"> * @param body Markdown comment body</text>
|
||||
<text x="22" y="180" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13"> */</text>
|
||||
<text x="22" y="200" fill="#7dd3fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="600">function</text>
|
||||
<text x="100" y="200" fill="#fde68a" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="600">create_comment</text>
|
||||
<text x="226" y="200" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">(issueId, body);</text>
|
||||
|
||||
<text x="22" y="234" fill="#a78bfa" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ mcporter call linear.create_comment \\</text>
|
||||
<text x="36" y="252" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">issueId:ENG-123 body:'lgtm'</text>
|
||||
|
||||
<rect x="22" y="266" width="420" height="60" rx="10" fill="#06090f" stroke="#1f2937"/>
|
||||
<text x="38" y="290" fill="#fbbf24" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{</text>
|
||||
<text x="54" y="308" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"ok": true, "comment": { "id": "abc123" }</text>
|
||||
<text x="38" y="326" fill="#fbbf24" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">}</text>
|
||||
</g>
|
||||
|
||||
<!-- Capability pills below terminal -->
|
||||
<g transform="translate(670 514)" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="14" font-weight="600">
|
||||
<g>
|
||||
<rect x="0" y="0" width="92" height="30" rx="15" fill="#0e121b" stroke="#262a36"/>
|
||||
<text x="46" y="20" fill="#a78bfa" text-anchor="middle">Runtime</text>
|
||||
</g>
|
||||
<g transform="translate(104 0)">
|
||||
<rect x="0" y="0" width="56" height="30" rx="15" fill="#0e121b" stroke="#262a36"/>
|
||||
<text x="28" y="20" fill="#06b6d4" text-anchor="middle">CLI</text>
|
||||
</g>
|
||||
<g transform="translate(174 0)">
|
||||
<rect x="0" y="0" width="78" height="30" rx="15" fill="#0e121b" stroke="#262a36"/>
|
||||
<text x="39" y="20" fill="#10b981" text-anchor="middle">OAuth</text>
|
||||
</g>
|
||||
<g transform="translate(266 0)">
|
||||
<rect x="0" y="0" width="68" height="30" rx="15" fill="#0e121b" stroke="#262a36"/>
|
||||
<text x="34" y="20" fill="#ec4899" text-anchor="middle">stdio</text>
|
||||
</g>
|
||||
<g transform="translate(348 0)">
|
||||
<rect x="0" y="0" width="64" height="30" rx="15" fill="#0e121b" stroke="#262a36"/>
|
||||
<text x="32" y="20" fill="#f59e0b" text-anchor="middle">HTTP</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@ -31,7 +31,7 @@ summary: 'Plan for the mcporter package replacing the Sweetistics pnpm MCP helpe
|
||||
- Automatically detect OAuth requirements for ad-hoc HTTP servers by retrying failed handshakes and promoting the definition to `auth: "oauth"` when a 401/403 is encountered, then launching the browser flow immediately.
|
||||
- Mirror Python helper behavior:
|
||||
- `${VAR}`, `${VAR:-default}`, `$env:VAR` interpolation.
|
||||
- Optional OAuth token cache directory handling (defaulting to `~/.mcporter/<server>` when none is provided).
|
||||
- Optional OAuth token cache directory handling (defaulting to `~/.mcporter/<server>` when none is provided, or XDG paths when configured).
|
||||
- Tool signature + schema fetching for `list`.
|
||||
- Provide lazy connection pooling per server to minimize startup cost.
|
||||
- Expose a lightweight server proxy (`createServerProxy`) that maps camelCase method accesses to tool names, fills JSON-schema defaults, validates required arguments, and returns a helper (`CallResult`) for extracting text/markdown/JSON without re-parsing the content envelope.
|
||||
@ -39,7 +39,7 @@ summary: 'Plan for the mcporter package replacing the Sweetistics pnpm MCP helpe
|
||||
|
||||
## Schema-Aware Proxy Strategy
|
||||
|
||||
- Cache tool schemas on first access, persist them under `~/.mcporter/<server>/schema.json` for reuse across processes, and tolerate failures by falling back to raw `callTool`.
|
||||
- Cache tool schemas on first access, persist them under `~/.mcporter/<server>/schema.json` or `$XDG_CACHE_HOME/mcporter/<server>/schema.json` for reuse across processes, and tolerate failures by falling back to raw `callTool`.
|
||||
- Allow direct method-style invocations such as `context7.getLibraryDocs("react")` by:
|
||||
- Mapping camelCase properties to kebab-case tool names.
|
||||
- Detecting positional arguments and assigning them to required schema fields in order.
|
||||
|
||||
@ -30,4 +30,4 @@ Use `tmux` to verify whether a CLI command actually exits or is stalled on open
|
||||
tmux kill-session -t mcporter-check
|
||||
```
|
||||
|
||||
This workflow makes it easy to confirm whether `mcporter` commands return promptly after shutdown changes (for example, when debugging lingering MCP stdio servers). Use `MCPORTER_DEBUG_HANG=1` to emit active-handle diagnostics inside the tmux session when necessary. For OAuth flows that keep a session open, set `--oauth-timeout 5000` (or `MCPORTER_OAUTH_TIMEOUT_MS=5000`) so the CLI proves it can exit without waiting a full minute for a browser callback.
|
||||
This workflow makes it easy to confirm whether `mcporter` commands return promptly after shutdown changes (for example, when debugging lingering MCP stdio servers). Use `MCPORTER_DEBUG_HANG=1` to emit active-handle diagnostics inside the tmux session when necessary. For OAuth flows that keep a session open, set `--oauth-timeout 5000` (or `MCPORTER_OAUTH_TIMEOUT_MS=5000`) so the CLI proves it can exit without waiting the full 5 minute default for a browser callback.
|
||||
|
||||
@ -30,6 +30,7 @@ mcporter call context7.resolve-library-id libraryName: value
|
||||
|
||||
- Use `--flag value` when you prefer long-form CLI syntax.
|
||||
- Mixed forms are fine: `mcporter call linear.create_issue --team ENG title=value due: tomorrow`.
|
||||
- Use `body=@comment.md` (or `--body @comment.md`) to read an exact UTF-8 string from a file; use `body=@@literal` when the value itself starts with `@`.
|
||||
- `--args '{"title":"Bug"}'` still ingests JSON payloads directly.
|
||||
- Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`.
|
||||
|
||||
@ -37,12 +38,12 @@ mcporter call context7.resolve-library-id libraryName: value
|
||||
|
||||
```bash
|
||||
mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'
|
||||
mcporter 'context7.resolve-library-id(libraryName: "react")'
|
||||
mcporter 'context7.resolve-library-id("react")'
|
||||
mcporter 'context7.resolve-library-id(query: "React hooks docs", libraryName: "react")'
|
||||
mcporter 'context7.resolve-library-id("React hooks docs", "react")'
|
||||
```
|
||||
|
||||
- Mirrors the pseudo-TypeScript signature printed by `mcporter list`.
|
||||
- You may omit labels and rely on the schema order—`mcporter 'context7.resolve-library-id("react")'` maps the first argument to `libraryName` automatically.
|
||||
- You may omit labels and rely on the schema order—`mcporter 'context7.resolve-library-id("React hooks docs", "react")'` maps arguments to the live schema order automatically.
|
||||
- Supports nested objects/arrays and gives detailed parser errors when the expression is malformed.
|
||||
- Wrap the whole expression in quotes so the shell leaves parentheses/commas intact.
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
@ -59,8 +59,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Working directory for stdio servers. A leading ~ is expanded to $HOME; relative paths resolve against the config file directory",
|
||||
"type": "string"
|
||||
},
|
||||
"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",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
@ -70,7 +74,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
@ -99,6 +103,38 @@
|
||||
"description": "Client identifier for server telemetry (snake_case)",
|
||||
"type": "string"
|
||||
},
|
||||
"oauthClientId": {
|
||||
"description": "Pre-registered OAuth client id (camelCase)",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth_client_id": {
|
||||
"description": "Pre-registered OAuth client id (snake_case)",
|
||||
"type": "string"
|
||||
},
|
||||
"oauthClientSecret": {
|
||||
"description": "Pre-registered OAuth client secret (camelCase)",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth_client_secret": {
|
||||
"description": "Pre-registered OAuth client secret (snake_case)",
|
||||
"type": "string"
|
||||
},
|
||||
"oauthClientSecretEnv": {
|
||||
"description": "Environment variable containing the OAuth client secret",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth_client_secret_env": {
|
||||
"description": "Environment variable containing the OAuth client secret",
|
||||
"type": "string"
|
||||
},
|
||||
"oauthTokenEndpointAuthMethod": {
|
||||
"description": "OAuth token endpoint auth method, e.g. client_secret_post",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth_token_endpoint_auth_method": {
|
||||
"description": "OAuth token endpoint auth method, e.g. client_secret_post",
|
||||
"type": "string"
|
||||
},
|
||||
"oauthRedirectUrl": {
|
||||
"description": "Custom OAuth redirect URL (camelCase)",
|
||||
"type": "string"
|
||||
@ -161,6 +197,75 @@
|
||||
"description": "Environment variable name containing the bearer token (snake_case)",
|
||||
"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": {
|
||||
"anyOf": [
|
||||
{
|
||||
@ -219,6 +324,34 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"allowedTools": {
|
||||
"description": "Only these exact tool names are exposed (camelCase)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"allowed_tools": {
|
||||
"description": "Only these exact tool names are exposed (snake_case)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"blockedTools": {
|
||||
"description": "These exact tool names are hidden and blocked (camelCase)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"blocked_tools": {
|
||||
"description": "These exact tool names are hidden and blocked (snake_case)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@ -226,6 +359,18 @@
|
||||
},
|
||||
"description": "Map of server names to their configurations"
|
||||
},
|
||||
"daemonIdleTimeoutMs": {
|
||||
"description": "Idle timeout in milliseconds before shutting down an inactive daemon",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"daemon_idle_timeout_ms": {
|
||||
"description": "Idle timeout in milliseconds before shutting down an inactive daemon",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"imports": {
|
||||
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
||||
"type": "array",
|
||||
|
||||
67
package.json
67
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcporter",
|
||||
"version": "0.8.1",
|
||||
"version": "0.12.1",
|
||||
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@ -12,7 +12,7 @@
|
||||
"author": "Sweetistics",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/steipete/mcporter"
|
||||
"url": "git+https://github.com/openclaw/mcporter.git"
|
||||
},
|
||||
"bin": {
|
||||
"mcporter": "dist/cli.js"
|
||||
@ -42,71 +42,72 @@
|
||||
"scripts": {
|
||||
"mcporter": "tsx src/cli.ts",
|
||||
"mcp": "pnpm exec tsx src/cli.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"build:bun": "bun scripts/build-bun.ts",
|
||||
"check": "pnpm format:check && pnpm lint:oxlint && pnpm typecheck",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check .",
|
||||
"lint": "pnpm check",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.json --report-unused-disable-directives --deny-warnings --max-warnings=0",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.json --report-unused-disable-directives --deny-warnings --max-warnings=0 --allow eslint/no-underscore-dangle",
|
||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||
"test": "cross-env MCPORTER_TEST_REPORTER=quiet pnpm test:verbose",
|
||||
"test:quiet": "cross-env MCPORTER_TEST_REPORTER=quiet pnpm test:verbose",
|
||||
"test:verbose": "node scripts/test-runner.js",
|
||||
"test:verbose": "pnpm build && node scripts/test-runner.js",
|
||||
"test:live": "MCP_LIVE_TESTS=1 vitest run tests/live",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "tsc -w -p tsconfig.build.json",
|
||||
"dev": "tsgo -w -p tsconfig.build.json",
|
||||
"prepublishOnly": "pnpm check && pnpm test && pnpm build",
|
||||
"docs:list": "pnpm exec tsx scripts/docs-list.ts",
|
||||
"docs:site": "node scripts/build-docs-site.mjs",
|
||||
"generate:schema": "tsx scripts/generate-json-schema.ts",
|
||||
"mcporter:list": "pnpm exec tsx src/cli.ts list",
|
||||
"mcporter:call": "pnpm exec tsx src/cli.ts call"
|
||||
"mcporter:call": "pnpm exec tsx src/cli.ts call",
|
||||
"check:changed": "pnpm run check",
|
||||
"test:changed": "pnpm run test",
|
||||
"crabbox:hydrate": "crabbox actions hydrate",
|
||||
"crabbox:run": "crabbox run",
|
||||
"crabbox:stop": "crabbox stop",
|
||||
"crabbox:warmup": "crabbox warmup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"acorn": "^8.16.0",
|
||||
"commander": "^14.0.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"acorn": "^8.17.0",
|
||||
"commander": "^15.0.0",
|
||||
"es-toolkit": "^1.48.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"ora": "^9.3.0",
|
||||
"rolldown": "1.0.0-rc.16",
|
||||
"zod": "^4.3.6"
|
||||
"ora": "^9.4.1",
|
||||
"rolldown": "1.1.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/estree": "^1.0.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260418.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"bun-types": "^1.3.12",
|
||||
"@types/node": "^26.0.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260623.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"bun-types": "^1.3.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"express": "^5.2.1",
|
||||
"oxfmt": "^0.45.0",
|
||||
"oxlint": "^1.60.0",
|
||||
"oxlint-tsgolint": "^0.21.1",
|
||||
"oxfmt": "^0.56.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4"
|
||||
"vite": "8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"devEngines": {
|
||||
"runtime": [
|
||||
{
|
||||
"name": "node",
|
||||
"version": ">=20"
|
||||
"version": ">=24"
|
||||
}
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
"node": ">=24"
|
||||
},
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"body-parser": "2.2.1",
|
||||
"vite": "8.0.8"
|
||||
}
|
||||
}
|
||||
"packageManager": "pnpm@10.33.2"
|
||||
}
|
||||
|
||||
1938
pnpm-lock.yaml
generated
1938
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,9 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
overrides:
|
||||
body-parser: 2.2.1
|
||||
esbuild: 0.28.1
|
||||
hono: 4.12.25
|
||||
ip-address: 10.1.1
|
||||
qs: 6.15.2
|
||||
vite: 8.0.16
|
||||
|
||||
702
scripts/build-docs-site.mjs
Normal file
702
scripts/build-docs-site.mjs
Normal file
@ -0,0 +1,702 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { css, faviconSvg, js, preThemeScript, themeToggleHtml } from './docs-site-assets.mjs';
|
||||
|
||||
const root = process.cwd();
|
||||
const docsDir = path.join(root, 'docs');
|
||||
const outDir = path.join(root, 'dist', 'docs-site');
|
||||
const repoBase = 'https://github.com/openclaw/mcporter';
|
||||
const repoEditBase = `${repoBase}/edit/main/docs`;
|
||||
const cname = readCname();
|
||||
const siteBase = cname ? `https://${cname}` : '';
|
||||
|
||||
const productName = 'mcporter';
|
||||
const productTagline = 'MCP, made portable.';
|
||||
const productDescription =
|
||||
'TypeScript runtime, CLI, and code-generation toolkit for the Model Context Protocol — built so AI agents and developers can call any MCP server without boilerplate.';
|
||||
const brewInstall = 'npx mcporter list';
|
||||
|
||||
const sections = [
|
||||
['Start', ['index.md', 'install.md', 'quickstart.md', 'config.md']],
|
||||
['CLI', ['cli-reference.md', 'call-syntax.md', 'call-heuristic.md', 'shortcuts.md', 'logging.md']],
|
||||
['Generators', ['cli-generator.md', 'emit-ts.md', 'tool-calling.md']],
|
||||
['Connecting servers', ['adhoc.md', 'import.md', 'local.md', 'daemon.md', 'mcp.md']],
|
||||
['Agents', ['agent-skills.md', 'subagent.md']],
|
||||
[
|
||||
'Operations',
|
||||
[
|
||||
'RELEASE.md',
|
||||
'manual-testing.md',
|
||||
'livetests.md',
|
||||
'hang-debug.md',
|
||||
'windows.md',
|
||||
'tmux.md',
|
||||
'known-issues.md',
|
||||
'supabase-auth-issue.md',
|
||||
],
|
||||
],
|
||||
['Reference', ['spec.md', 'migration.md', 'pnpm-mcp-migration.md', 'refactor.md']],
|
||||
];
|
||||
|
||||
// Skip these from page generation (internal notes etc.). Pages excluded here are
|
||||
// neither rendered nor link-validated.
|
||||
const buildExcludes = [];
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const allPages = allMarkdown(docsDir).map((file) => {
|
||||
const rel = path.relative(docsDir, file).replaceAll(path.sep, '/');
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const cleaned = stripStrayDirectives(body);
|
||||
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, '.md'));
|
||||
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
|
||||
});
|
||||
|
||||
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
|
||||
const pageMap = new Map(pages.map((page) => [page.rel, page]));
|
||||
const permalinkMap = new Map();
|
||||
for (const page of pages) {
|
||||
if (page.frontmatter.permalink) {
|
||||
permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
|
||||
}
|
||||
}
|
||||
|
||||
const nav = sections
|
||||
.map(([name, rels]) => ({
|
||||
name,
|
||||
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
|
||||
}))
|
||||
.filter((section) => section.pages.length);
|
||||
|
||||
// Catch-all section: any docs/*.md we didn't slot into the curated nav goes
|
||||
// under "More". This keeps every doc reachable without forcing the author to
|
||||
// hand-edit `sections` for every new file.
|
||||
const navRels = new Set(nav.flatMap((s) => s.pages.map((p) => p.rel)));
|
||||
const extras = pages
|
||||
.filter((page) => !navRels.has(page.rel) && page.rel !== 'index.md')
|
||||
.toSorted((a, b) => a.title.localeCompare(b.title));
|
||||
if (extras.length) nav.push({ name: 'More', pages: extras });
|
||||
|
||||
const sectionByRel = new Map();
|
||||
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
|
||||
const orderedPages = nav.flatMap((s) => s.pages);
|
||||
|
||||
for (const page of pages) {
|
||||
const html = markdownToHtml(page.markdown, page.rel);
|
||||
const toc = tocFromHtml(html);
|
||||
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
|
||||
const prev = idx > 0 ? orderedPages[idx - 1] : null;
|
||||
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
|
||||
const sectionName = sectionByRel.get(page.rel) || 'Reference';
|
||||
const pageOut = path.join(outDir, page.outRel);
|
||||
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
|
||||
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), 'utf8');
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outDir, 'favicon.svg'), faviconSvg(), 'utf8');
|
||||
copyStaticAsset('social-card.svg');
|
||||
copyStaticAsset('social-card.png');
|
||||
fs.writeFileSync(path.join(outDir, '.nojekyll'), '', 'utf8');
|
||||
if (cname) fs.writeFileSync(path.join(outDir, 'CNAME'), cname, 'utf8');
|
||||
validateLinks(outDir);
|
||||
fs.writeFileSync(path.join(outDir, 'llms.txt'), llmsTxt(), 'utf8');
|
||||
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() {
|
||||
for (const candidate of [path.join(docsDir, 'CNAME'), path.join(root, 'CNAME')]) {
|
||||
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function copyStaticAsset(name) {
|
||||
const source = path.join(docsDir, name);
|
||||
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(outDir, name));
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw) {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!match) return { frontmatter: {}, body: raw };
|
||||
const fm = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2];
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fm[m[1]] = value;
|
||||
}
|
||||
return { frontmatter: fm, body: raw.slice(match[0].length) };
|
||||
}
|
||||
|
||||
function stripStrayDirectives(body) {
|
||||
return body
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
|
||||
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function normalizePermalink(value) {
|
||||
let v = value.trim();
|
||||
if (!v) return '/';
|
||||
if (!v.startsWith('/')) v = `/${v}`;
|
||||
if (v.length > 1 && v.endsWith('/')) v = v.slice(0, -1);
|
||||
return v;
|
||||
}
|
||||
|
||||
function allMarkdown(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return allMarkdown(full);
|
||||
return entry.name.endsWith('.md') ? [full] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function outPath(rel, frontmatter = {}) {
|
||||
if (frontmatter.permalink) {
|
||||
const permalink = normalizePermalink(frontmatter.permalink);
|
||||
if (permalink === '/') return 'index.html';
|
||||
return `${permalink.slice(1)}/index.html`;
|
||||
}
|
||||
if (rel === 'index.md') return 'index.html';
|
||||
if (rel === 'README.md') return 'index.html';
|
||||
if (rel.endsWith('/README.md')) return rel.replace(/README\.md$/, 'index.html');
|
||||
return rel.replace(/\.md$/, '.html');
|
||||
}
|
||||
|
||||
function firstHeading(markdown) {
|
||||
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
||||
}
|
||||
|
||||
function titleize(input) {
|
||||
return input.replaceAll('-', ' ').replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown, currentRel) {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const html = [];
|
||||
let paragraph = [];
|
||||
let list = null;
|
||||
let fence = null;
|
||||
let blockquote = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph.length) return;
|
||||
html.push(`<p>${inline(paragraph.join(' '), currentRel)}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
const closeList = () => {
|
||||
if (!list) return;
|
||||
html.push(`</${list}>`);
|
||||
list = null;
|
||||
};
|
||||
const flushBlockquote = () => {
|
||||
if (!blockquote.length) return;
|
||||
const inner = markdownToHtml(blockquote.join('\n'), currentRel);
|
||||
html.push(`<blockquote>${inner}</blockquote>`);
|
||||
blockquote = [];
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
|
||||
if (fenceMatch) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
flushBlockquote();
|
||||
if (fence) {
|
||||
html.push(
|
||||
`<pre><code class="language-${escapeAttr(fence.lang)}">${escapeHtml(fence.lines.join('\n'))}</code></pre>`
|
||||
);
|
||||
fence = null;
|
||||
} else {
|
||||
fence = { lang: fenceMatch[1] || 'text', lines: [] };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fence) {
|
||||
fence.lines.push(line);
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
blockquote.push(line.replace(/^>\s?/, ''));
|
||||
continue;
|
||||
}
|
||||
flushBlockquote();
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
if (/^\s*---+\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
html.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,4})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
const text = heading[2].trim();
|
||||
const id = slug(text);
|
||||
const inner = inline(text, currentRel);
|
||||
if (level === 1) {
|
||||
html.push(`<h1 id="${id}">${inner}</h1>`);
|
||||
} else {
|
||||
html.push(
|
||||
`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
line.trimStart().startsWith('|') &&
|
||||
line.includes('|', line.indexOf('|') + 1) &&
|
||||
isDivider(lines[i + 1] || '')
|
||||
) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const header = splitRow(line);
|
||||
const aligns = splitRow(lines[i + 1]).map((cell) => {
|
||||
const left = cell.startsWith(':');
|
||||
const right = cell.endsWith(':');
|
||||
return right && left ? 'center' : right ? 'right' : left ? 'left' : '';
|
||||
});
|
||||
i += 1;
|
||||
const rows = [];
|
||||
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('|')) {
|
||||
i += 1;
|
||||
rows.push(splitRow(lines[i]));
|
||||
}
|
||||
const th = header
|
||||
.map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''}>${inline(c, currentRel)}</th>`)
|
||||
.join('');
|
||||
const tb = rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''}>${inline(c, currentRel)}</td>`).join('')}</tr>`
|
||||
)
|
||||
.join('');
|
||||
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
|
||||
continue;
|
||||
}
|
||||
const bullet = line.match(/^\s*-\s+(.+)$/);
|
||||
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (bullet || numbered) {
|
||||
flushParagraph();
|
||||
const tag = bullet ? 'ul' : 'ol';
|
||||
if (list && list !== tag) closeList();
|
||||
if (!list) {
|
||||
list = tag;
|
||||
html.push(`<${tag}>`);
|
||||
}
|
||||
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
|
||||
continue;
|
||||
}
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
flushParagraph();
|
||||
closeList();
|
||||
flushBlockquote();
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function inline(text, currentRel) {
|
||||
const stash = [];
|
||||
let out = text.replace(/`([^`]+)`/g, (_, code) => {
|
||||
stash.push(`<code>${escapeHtml(code)}</code>`);
|
||||
return `\uE000${stash.length - 1}\uE000`;
|
||||
});
|
||||
out = escapeHtml(out)
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1<em>$2</em>')
|
||||
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, '$1<em>$2</em>')
|
||||
.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`
|
||||
)
|
||||
.replace(/<(https?:\/\/[^\s<>]+)>/g, '<a href="$1">$1</a>');
|
||||
out = out.replace(/\\\|/g, '|');
|
||||
out = out.replace(/<br>/g, '<br>');
|
||||
return out.replace(/\uE000(\d+)\uE000/g, (_, i) => stash[Number(i)]);
|
||||
}
|
||||
|
||||
function splitRow(line) {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
|
||||
if (trimmed.endsWith('|') && !trimmed.endsWith('\\|')) trimmed = trimmed.slice(0, -1);
|
||||
const cells = [];
|
||||
let current = '';
|
||||
for (let idx = 0; idx < trimmed.length; idx++) {
|
||||
const char = trimmed[idx];
|
||||
if (char === '\\' && trimmed[idx + 1] === '|') {
|
||||
current += '\\|';
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '|') {
|
||||
cells.push(current.trim().replace(/\\\|/g, '|'));
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current.trim().replace(/\\\|/g, '|'));
|
||||
return cells;
|
||||
}
|
||||
|
||||
function isDivider(line) {
|
||||
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
|
||||
}
|
||||
|
||||
function rewriteHref(href, currentRel) {
|
||||
if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
|
||||
const [raw, hash = ''] = href.split('#');
|
||||
if (!raw) return hash ? `#${hash}` : '';
|
||||
if (raw.startsWith('/')) {
|
||||
const target = permalinkMap.get(normalizePermalink(raw));
|
||||
if (target) {
|
||||
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
|
||||
const out = hrefToOutRel(target.outRel, currentOut);
|
||||
return hash ? `${out}#${hash}` : out;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
if (!raw.endsWith('.md')) return href;
|
||||
const from = path.posix.dirname(currentRel);
|
||||
const target = path.posix.normalize(path.posix.join(from, raw));
|
||||
let rewritten = pageMap.get(target)?.outRel || outPath(target);
|
||||
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
|
||||
rewritten = hrefToOutRel(rewritten, currentOut);
|
||||
return `${rewritten}${hash ? `#${hash}` : ''}`;
|
||||
}
|
||||
|
||||
function tocFromHtml(html) {
|
||||
const items = [];
|
||||
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const text = m[3]
|
||||
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
items.push({ level: Number(m[1]), id: m[2], text });
|
||||
}
|
||||
if (items.length < 2) return '';
|
||||
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
|
||||
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
|
||||
.join('')}</nav>`;
|
||||
}
|
||||
|
||||
function isHomePage(page) {
|
||||
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === '/') return true;
|
||||
return page.rel === 'index.md' || page.rel === 'README.md';
|
||||
}
|
||||
|
||||
function homeHero(page) {
|
||||
const description = page.frontmatter.description || productDescription;
|
||||
const installRel = pageMap.get('install.md')?.outRel
|
||||
? hrefToOutRel(pageMap.get('install.md').outRel, page.outRel)
|
||||
: 'install.html';
|
||||
const quickstartRel = pageMap.get('quickstart.md')?.outRel
|
||||
? hrefToOutRel(pageMap.get('quickstart.md').outRel, page.outRel)
|
||||
: 'quickstart.html';
|
||||
const features = ['TypeScript runtime', 'CLI', 'Generated CLIs', 'Typed clients', 'OAuth', 'stdio + HTTP + SSE'];
|
||||
return `<header class="home-hero">
|
||||
<p class="eyebrow">Model Context Protocol · Toolkit</p>
|
||||
<h1>${escapeHtml(productTagline)}</h1>
|
||||
<p class="lede">${escapeHtml(description)}</p>
|
||||
<div class="home-cta">
|
||||
<a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
|
||||
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
|
||||
<div class="home-install" aria-label="Try with npx">
|
||||
<span class="prompt" aria-hidden="true">$</span>
|
||||
<code>${escapeHtml(brewInstall)}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-services" aria-label="Capabilities">
|
||||
${features.map((s) => `<span>${escapeHtml(s)}</span>`).join('')}
|
||||
</div>
|
||||
<p class="muted"><a href="${installRel}">Install options →</a></p>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function standardHero(page, sectionName, editUrl) {
|
||||
return `<header class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">${escapeHtml(sectionName)}</p>
|
||||
<h1>${escapeHtml(page.title)}</h1>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
|
||||
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function layout({ page, html, toc, prev, next, sectionName }) {
|
||||
const depth = page.outRel.split('/').length - 1;
|
||||
const rootPrefix = depth ? '../'.repeat(depth) : '';
|
||||
const editUrl = `${repoEditBase}/${page.rel}`;
|
||||
const home = isHomePage(page);
|
||||
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : '';
|
||||
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
|
||||
const articleClass = home ? 'doc doc-home' : 'doc';
|
||||
const tocBlock = home ? '' : toc;
|
||||
const titleSuffix = home ? `${productName} — ${productTagline}` : `${page.title} — ${productName}`;
|
||||
const description =
|
||||
page.frontmatter.description || (home ? productDescription : `${page.title} — ${productName} documentation.`);
|
||||
const canonicalUrl = pageCanonicalUrl(page);
|
||||
const socialImage = siteBase ? `${siteBase}/social-card.png` : `${rootPrefix}social-card.png`;
|
||||
const socialMeta = [
|
||||
['link', 'rel', 'canonical', 'href', canonicalUrl],
|
||||
['meta', 'property', 'og:type', 'content', 'website'],
|
||||
['meta', 'property', 'og:site_name', 'content', productName],
|
||||
['meta', 'property', 'og:title', 'content', titleSuffix],
|
||||
['meta', 'property', 'og:description', 'content', description],
|
||||
['meta', 'property', 'og:url', 'content', canonicalUrl],
|
||||
['meta', 'property', 'og:image', 'content', socialImage],
|
||||
['meta', 'property', 'og:image:width', 'content', '1200'],
|
||||
['meta', 'property', 'og:image:height', 'content', '630'],
|
||||
['meta', 'name', 'twitter:card', 'content', 'summary_large_image'],
|
||||
['meta', 'name', 'twitter:title', 'content', titleSuffix],
|
||||
['meta', 'name', 'twitter:description', 'content', description],
|
||||
['meta', 'name', 'twitter:image', 'content', socialImage],
|
||||
]
|
||||
.map(tagHtml)
|
||||
.join('\n ');
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(titleSuffix)}</title>
|
||||
<meta name="description" content="${escapeAttr(description)}">
|
||||
${socialMeta}
|
||||
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>${preThemeScript()}</script>
|
||||
<style>${css()}</style>
|
||||
</head>
|
||||
<body${home ? ' class="home"' : ''}>
|
||||
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
|
||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<a class="brand" href="${hrefToOutRel('index.html', page.outRel)}" aria-label="${productName} docs home">
|
||||
<span class="mark" aria-hidden="true"></span>
|
||||
<span><strong>${escapeHtml(productName)}</strong><small>MCP toolkit docs</small></span>
|
||||
</a>
|
||||
${themeToggleHtml()}
|
||||
</div>
|
||||
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="call, generate-cli, oauth"></label>
|
||||
<nav>${navHtml(page)}</nav>
|
||||
</aside>
|
||||
<main>
|
||||
${heroBlock}
|
||||
<div class="doc-grid${home ? ' doc-grid-home' : ''}">
|
||||
<article class="${articleClass}">${html}${prevNext}</article>
|
||||
${tocBlock}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>${js()}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function pageCanonicalUrl(page) {
|
||||
if (!siteBase) return page.outRel;
|
||||
if (page.outRel === 'index.html') return `${siteBase}/`;
|
||||
const rel = page.outRel.endsWith('/index.html') ? page.outRel.slice(0, -'index.html'.length) : page.outRel;
|
||||
return `${siteBase}/${rel}`;
|
||||
}
|
||||
|
||||
function tagHtml([tag, k1, v1, k2, v2]) {
|
||||
return tag === 'link'
|
||||
? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`
|
||||
: `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
|
||||
}
|
||||
|
||||
function pageNavHtml(prev, next, currentOutRel) {
|
||||
const cell = (page, dir) => {
|
||||
if (!page) return '';
|
||||
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === 'prev' ? 'Previous' : 'Next'}</small><span>${escapeHtml(page.title)}</span></a>`;
|
||||
};
|
||||
return `<nav class="page-nav" aria-label="Pager">${cell(prev, 'prev')}${cell(next, 'next')}</nav>`;
|
||||
}
|
||||
|
||||
function navHtml(currentPage) {
|
||||
return nav
|
||||
.map(
|
||||
(section) =>
|
||||
`<section><h2>${escapeHtml(section.name)}</h2>${section.pages
|
||||
.map((page) => {
|
||||
const href = hrefToOutRel(page.outRel, currentPage.outRel);
|
||||
const active = page.rel === currentPage.rel ? ' active' : '';
|
||||
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
|
||||
})
|
||||
.join('')}</section>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function navTitle(page) {
|
||||
if (page.rel === 'index.md') return 'Overview';
|
||||
return page.title.replace(/^`mcporter\s*/, '').replace(/`$/, '');
|
||||
}
|
||||
|
||||
function hrefToOutRel(targetOutRel, currentOutRel) {
|
||||
const currentDir = path.posix.dirname(currentOutRel);
|
||||
if (targetOutRel.endsWith('/index.html')) {
|
||||
const targetDir = targetOutRel.slice(0, -'index.html'.length);
|
||||
const rel = path.posix.relative(currentDir, targetDir || '.') || '.';
|
||||
return rel.endsWith('/') ? rel : `${rel}/`;
|
||||
}
|
||||
if (targetOutRel === 'index.html') {
|
||||
const rel = path.posix.relative(currentDir, '.') || '.';
|
||||
return rel.endsWith('/') ? rel : `${rel}/`;
|
||||
}
|
||||
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
|
||||
}
|
||||
|
||||
function slug(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/`/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]
|
||||
);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function validateLinks(outputDir) {
|
||||
const failures = [];
|
||||
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
|
||||
for (const file of allHtml(outputDir)) {
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
for (const match of html.matchAll(/href="([^"]+)"/g)) {
|
||||
const href = match[1];
|
||||
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
|
||||
if (placeholderHrefs.test(href)) continue;
|
||||
const [rawPath, anchor = ''] = href.split('#');
|
||||
const targetPath = rawPath ? path.resolve(path.dirname(file), rawPath) : file;
|
||||
const target =
|
||||
fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
||||
? path.join(targetPath, 'index.html')
|
||||
: targetPath;
|
||||
if (!fs.existsSync(target)) {
|
||||
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
|
||||
continue;
|
||||
}
|
||||
if (anchor) {
|
||||
const targetHtml = fs.readFileSync(target, 'utf8');
|
||||
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
|
||||
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.length) {
|
||||
throw new Error(`broken docs links:\n${failures.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function allHtml(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return allHtml(full);
|
||||
return entry.name.endsWith('.html') ? [full] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
286
scripts/docs-site-assets.mjs
Normal file
286
scripts/docs-site-assets.mjs
Normal file
@ -0,0 +1,286 @@
|
||||
export function css() {
|
||||
return `
|
||||
:root{
|
||||
--ink:#0d1116;
|
||||
--text:#1f2530;
|
||||
--muted:#5f6b7a;
|
||||
--subtle:#94a0b1;
|
||||
--bg:#f7f9fc;
|
||||
--paper:#ffffff;
|
||||
--accent:#7c3aed;
|
||||
--accent-soft:rgba(124,58,237,.10);
|
||||
--accent-strong:#5b21b6;
|
||||
--brand-cyan:#06b6d4;
|
||||
--brand-pink:#ec4899;
|
||||
--brand-amber:#f59e0b;
|
||||
--brand-emerald:#10b981;
|
||||
--line:#e3e7ef;
|
||||
--line-soft:#eef1f6;
|
||||
--code-bg:#0b0d12;
|
||||
--code-fg:#e6edf3;
|
||||
--code-inline-fg:#1c2128;
|
||||
--pill-border:#dbe2eb;
|
||||
--shadow-card:0 4px 14px rgba(15,17,21,.08);
|
||||
--scrollbar:#cbd5e1;
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--ink:#f3f5f9;
|
||||
--text:#cbd2dc;
|
||||
--muted:#8d96a4;
|
||||
--subtle:#5d6371;
|
||||
--bg:#0a0c12;
|
||||
--paper:#13161f;
|
||||
--accent:#a78bfa;
|
||||
--accent-soft:rgba(167,139,250,.16);
|
||||
--accent-strong:#c4b5fd;
|
||||
--line:#262a36;
|
||||
--line-soft:#1d2029;
|
||||
--code-bg:#06080d;
|
||||
--code-fg:#e6edf3;
|
||||
--code-inline-fg:#e6edf3;
|
||||
--pill-border:#2a2f3c;
|
||||
--shadow-card:0 4px 18px rgba(0,0,0,.45);
|
||||
--scrollbar:#3a4154;
|
||||
}
|
||||
:root{color-scheme:light}
|
||||
:root[data-theme="dark"]{color-scheme:dark}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth;scroll-padding-top:24px}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11";transition:background-color .18s,color .18s}
|
||||
::selection{background:var(--accent);color:#fff}
|
||||
a{color:var(--accent);text-decoration:none;transition:color .12s}
|
||||
a:hover{text-decoration:underline;text-underline-offset:.2em}
|
||||
.shell{display:grid;grid-template-columns:268px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .18s,border-color .18s}
|
||||
.sidebar::-webkit-scrollbar{width:6px}
|
||||
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
|
||||
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
|
||||
.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
|
||||
.brand:hover{text-decoration:none}
|
||||
.brand .mark{position:relative;flex:0 0 32px;width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,var(--accent) 0%,var(--brand-cyan) 100%);box-shadow:0 1px 2px rgba(15,17,21,.18),inset 0 1px 0 rgba(255,255,255,.18)}
|
||||
.brand .mark::before,.brand .mark::after{content:"";position:absolute;background:#fff;border-radius:2px}
|
||||
.brand .mark::before{left:7px;right:7px;top:9px;height:3px;opacity:.95}
|
||||
.brand .mark::after{left:13px;right:13px;top:14px;bottom:7px;border-radius:1px;opacity:.85}
|
||||
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:0;color:var(--ink)}
|
||||
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400}
|
||||
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s,color .15s,background-color .15s,transform .12s}
|
||||
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
|
||||
.theme-toggle:active{transform:scale(.94)}
|
||||
.theme-toggle svg{width:16px;height:16px;display:block}
|
||||
.theme-icon-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-icon-sun{display:block}
|
||||
:root[data-theme="dark"] .theme-icon-moon{display:none}
|
||||
.search{display:block;margin:0 0 22px}
|
||||
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0;margin-bottom:7px}
|
||||
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s,background-color .18s}
|
||||
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
nav section{margin:0 0 18px}
|
||||
nav h2{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 6px;font-weight:600}
|
||||
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}
|
||||
.nav-link:hover{background:var(--line-soft);color:var(--ink);text-decoration:none}
|
||||
.nav-link.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%}
|
||||
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
|
||||
.hero-text{min-width:0;flex:1 1 320px}
|
||||
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;font-size:.7rem}
|
||||
.hero h1{font-size:2.25rem;line-height:1.1;letter-spacing:0;margin:0;font-weight:700;color:var(--ink)}
|
||||
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
|
||||
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s,color .15s,background .15s}
|
||||
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink);text-decoration:none}
|
||||
.edit{color:var(--muted)}
|
||||
.home-hero{padding:14px 0 28px;margin-bottom:8px;border-bottom:1px solid var(--line)}
|
||||
.home-hero h1{font-size:3.25rem;line-height:1.04;letter-spacing:0;margin:0 0 .35em;font-weight:700;color:var(--ink);background:linear-gradient(120deg,var(--accent) 0%,var(--brand-cyan) 60%,var(--brand-pink) 100%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--text);margin:0 0 1.2em;max-width:60ch}
|
||||
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 18px}
|
||||
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:10px 16px;font-weight:600;font-size:.92rem;text-decoration:none;transition:background .15s,border-color .15s,color .15s,transform .12s}
|
||||
.home-cta .btn-primary{background:var(--accent);color:#fff;border:1px solid var(--accent)}
|
||||
.home-cta .btn-primary:hover{background:var(--accent-strong);border-color:var(--accent-strong);text-decoration:none;color:#fff}
|
||||
.home-cta .btn-ghost{padding:10px 16px}
|
||||
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:10px 10px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;max-width:32em;border:1px solid #1f2937}
|
||||
.home-install .prompt{color:#64748b;user-select:none;flex:0 0 auto}
|
||||
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
|
||||
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:5px 11px;font:500 .72rem/1 "Inter",sans-serif;cursor:pointer;transition:background .15s,border-color .15s}
|
||||
.home-install .copy:hover{background:rgba(255,255,255,.16)}
|
||||
.home-install .copy.copied{background:var(--accent);border-color:var(--accent)}
|
||||
.home-services{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 18px}
|
||||
.home-services span{display:inline-block;padding:3px 9px;border:1px solid var(--line);border-radius:999px;font-size:.78rem;color:var(--muted);background:var(--paper)}
|
||||
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
|
||||
.doc-grid-home{margin-top:8px}
|
||||
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
|
||||
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
|
||||
.doc-home{max-width:76ch}
|
||||
.doc h1{font-size:2.6rem;line-height:1.08;letter-spacing:0;margin:0 0 .4em;font-weight:700;color:var(--ink)}
|
||||
body:not(.home) .doc>h1:first-child{display:none}
|
||||
.doc h2{font-size:1.45rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:0;color:var(--ink);position:relative}
|
||||
.doc h3{font-size:1.1rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0}
|
||||
.doc h4{font-size:.98rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600}
|
||||
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
|
||||
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s}
|
||||
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
|
||||
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--accent);text-decoration:none}
|
||||
.doc p{margin:0 0 1.05em}
|
||||
.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em}
|
||||
.doc li{margin:.25em 0}
|
||||
.doc li>p{margin:0 0 .4em}
|
||||
.doc strong{font-weight:600;color:var(--ink)}
|
||||
.doc em{font-style:italic}
|
||||
.doc code{font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;font-size:.84em;background:var(--line-soft);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--code-inline-fg)}
|
||||
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent;border:1px solid #1f2937}
|
||||
.doc pre::-webkit-scrollbar{height:8px;width: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 .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:hover .copy,.doc pre .copy:focus{opacity:1}
|
||||
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
|
||||
.doc pre .copy.copied{background:var(--accent);border-color:var(--accent);opacity:1}
|
||||
.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--accent);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)}
|
||||
.doc blockquote p:last-child{margin-bottom:0}
|
||||
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
|
||||
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}
|
||||
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
|
||||
.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0}
|
||||
.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
||||
.toc::-webkit-scrollbar{width:5px}
|
||||
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
|
||||
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 10px;font-weight:600}
|
||||
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s}
|
||||
.toc a:hover{color:var(--ink);text-decoration:none}
|
||||
.toc a.active{color:var(--accent);border-left-color:var(--accent);font-weight:500}
|
||||
.toc-l3{padding-left:22px!important;font-size:.94em}
|
||||
@media(max-width:1179px){.toc{display:none}}
|
||||
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px}
|
||||
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:9px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,box-shadow .15s,background-color .18s}
|
||||
.page-nav>a:hover{border-color:var(--accent);text-decoration:none;color:var(--ink)}
|
||||
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0;margin-bottom:5px;font-weight:600}
|
||||
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)}
|
||||
.page-nav-prev{text-align:left}
|
||||
.page-nav-next{text-align:right;grid-column:2}
|
||||
.page-nav-prev:only-child{grid-column:1}
|
||||
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:40px;height:40px;border-radius:9px;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
|
||||
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
|
||||
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
|
||||
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
|
||||
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
|
||||
@media(max-width:900px){
|
||||
.shell{display:block}
|
||||
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .18s,border-color .18s;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
|
||||
.sidebar.open{transform:translateX(0);pointer-events:auto}
|
||||
.nav-toggle{display:flex}
|
||||
main{padding:64px 18px 56px}
|
||||
.hero{padding-top:6px}
|
||||
.hero h1{font-size:1.8rem}
|
||||
.home-hero h1{font-size:2.45rem}
|
||||
.doc h1{font-size:2.1rem}
|
||||
.hero-meta{width:100%;justify-content:flex-start}
|
||||
.home-hero{padding-top:8px}
|
||||
.doc{padding:0}
|
||||
.doc-grid{margin-top:18px;gap:24px}
|
||||
.doc :is(h2,h3,h4) .anchor{display:none}
|
||||
}
|
||||
@media(max-width:520px){
|
||||
main{padding:60px 14px 48px}
|
||||
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
|
||||
.home-install{flex-wrap:wrap}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function js() {
|
||||
return `
|
||||
const themeRoot=document.documentElement;
|
||||
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
|
||||
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
|
||||
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
|
||||
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
|
||||
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
|
||||
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
|
||||
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const toggle=document.querySelector('.nav-toggle');
|
||||
const mobileNav=window.matchMedia('(max-width: 900px)');
|
||||
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
|
||||
function setSidebarFocusable(enabled){
|
||||
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
|
||||
if(enabled){
|
||||
if(el.dataset.sidebarTabindex!==undefined){
|
||||
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
|
||||
else el.removeAttribute('tabindex');
|
||||
delete el.dataset.sidebarTabindex;
|
||||
}
|
||||
}else if(el.dataset.sidebarTabindex===undefined){
|
||||
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
|
||||
el.setAttribute('tabindex','-1');
|
||||
}
|
||||
});
|
||||
}
|
||||
function setSidebarOpen(open){
|
||||
if(!sidebar||!toggle)return;
|
||||
sidebar.classList.toggle('open',open);
|
||||
toggle.setAttribute('aria-expanded',open?'true':'false');
|
||||
if(mobileNav.matches){
|
||||
sidebar.inert=!open;
|
||||
if(open)sidebar.removeAttribute('aria-hidden');
|
||||
else sidebar.setAttribute('aria-hidden','true');
|
||||
setSidebarFocusable(open);
|
||||
}else{
|
||||
sidebar.inert=false;
|
||||
sidebar.removeAttribute('aria-hidden');
|
||||
setSidebarFocusable(true);
|
||||
}
|
||||
}
|
||||
setSidebarOpen(false);
|
||||
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
|
||||
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
|
||||
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
|
||||
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
|
||||
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
|
||||
else mobileNav.addListener?.(syncSidebarForViewport);
|
||||
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'})});
|
||||
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)}
|
||||
highlightCodeBlocks();
|
||||
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
|
||||
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
|
||||
const tocLinks=document.querySelectorAll('.toc a');
|
||||
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
|
||||
`;
|
||||
}
|
||||
|
||||
export function preThemeScript() {
|
||||
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
|
||||
}
|
||||
|
||||
export function themeToggleHtml() {
|
||||
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
|
||||
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
|
||||
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
export function faviconSvg() {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="mcporter">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c3aed"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||
<rect x="14" y="22" width="36" height="22" rx="4" fill="#0b0d12"/>
|
||||
<rect x="22" y="16" width="20" height="8" rx="2" fill="#0b0d12"/>
|
||||
<rect x="14" y="29" width="36" height="2" fill="rgba(255,255,255,.18)"/>
|
||||
<circle cx="32" cy="38" r="3" fill="#7c3aed"/>
|
||||
</svg>`;
|
||||
}
|
||||
@ -10,7 +10,7 @@ RUNNER="${MCP_RUNNER:-./runner}"
|
||||
VERSION="${VERSION:-$(node -p "require('./package.json').version")}"
|
||||
|
||||
banner() { printf "\n==== %s ====" "$1"; printf "\n"; }
|
||||
run() { echo ">> $*"; "$@"; }
|
||||
run() { echo ">> $*" >&2; "$@"; }
|
||||
|
||||
phase_gates() {
|
||||
banner "Gates (lint/type/test/build)"
|
||||
@ -26,7 +26,7 @@ phase_artifacts() {
|
||||
run tar -C dist-bun -czf "$bun_tar" mcporter
|
||||
run shasum -a 256 "$bun_tar" | tee "${bun_tar}.sha256"
|
||||
|
||||
run "$RUNNER" npm pack --pack-destination /tmp
|
||||
run "$RUNNER" pnpm pack --pack-destination /tmp
|
||||
mv "/tmp/mcporter-${VERSION}.tgz" .
|
||||
run shasum "mcporter-${VERSION}.tgz" > "mcporter-${VERSION}.tgz.sha1"
|
||||
run shasum -a 256 "mcporter-${VERSION}.tgz" > "mcporter-${VERSION}.tgz.sha256"
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -23,8 +23,15 @@ export interface SerializedServerDefinition {
|
||||
readonly auth?: string;
|
||||
readonly tokenCacheDir?: string;
|
||||
readonly clientName?: string;
|
||||
readonly oauthClientId?: string;
|
||||
readonly oauthClientSecretEnv?: string;
|
||||
readonly oauthTokenEndpointAuthMethod?: string;
|
||||
readonly oauthRedirectUrl?: string;
|
||||
readonly oauthScope?: string;
|
||||
readonly refresh?: ServerDefinition['refresh'];
|
||||
readonly httpFetch?: ServerDefinition['httpFetch'];
|
||||
readonly allowedTools?: readonly string[];
|
||||
readonly blockedTools?: readonly string[];
|
||||
}
|
||||
|
||||
export interface CliArtifactMetadata {
|
||||
@ -67,16 +74,26 @@ export function metadataPathForArtifact(artifactPath: string): string {
|
||||
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
|
||||
// inspect command and falling back to legacy sidecar files.
|
||||
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||
let embeddedError: unknown;
|
||||
try {
|
||||
return await readMetadataFromCli(artifactPath);
|
||||
} catch (error) {
|
||||
embeddedError = error;
|
||||
}
|
||||
|
||||
const legacyPath = metadataPathForArtifact(artifactPath);
|
||||
try {
|
||||
const buffer = await fs.readFile(legacyPath, 'utf8');
|
||||
return JSON.parse(buffer) as CliArtifactMetadata;
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT') && embeddedError) {
|
||||
throw embeddedError;
|
||||
}
|
||||
if (!isErrno(error, 'ENOENT')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return await readMetadataFromCli(artifactPath);
|
||||
throw embeddedError;
|
||||
}
|
||||
|
||||
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||
@ -141,8 +158,15 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
||||
auth: definition.auth,
|
||||
tokenCacheDir: definition.tokenCacheDir,
|
||||
clientName: definition.clientName,
|
||||
oauthClientId: definition.oauthClientId,
|
||||
oauthClientSecretEnv: definition.oauthClientSecretEnv,
|
||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||
oauthScope: definition.oauthScope,
|
||||
refresh: definition.refresh,
|
||||
httpFetch: definition.httpFetch,
|
||||
allowedTools: definition.allowedTools,
|
||||
blockedTools: definition.blockedTools,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@ -158,7 +182,14 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
||||
auth: definition.auth,
|
||||
tokenCacheDir: definition.tokenCacheDir,
|
||||
clientName: definition.clientName,
|
||||
oauthClientId: definition.oauthClientId,
|
||||
oauthClientSecretEnv: definition.oauthClientSecretEnv,
|
||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||
oauthScope: definition.oauthScope,
|
||||
refresh: definition.refresh,
|
||||
httpFetch: definition.httpFetch,
|
||||
allowedTools: definition.allowedTools,
|
||||
blockedTools: definition.blockedTools,
|
||||
};
|
||||
}
|
||||
|
||||
541
src/cli.ts
541
src/cli.ts
@ -1,32 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
import { handleAuth, printAuthHelp } from './cli/auth-command.js';
|
||||
import { printCallHelp, handleCall as runHandleCall } from './cli/call-command.js';
|
||||
import { buildGlobalContext } from './cli/cli-factory.js';
|
||||
import { inferCommandRouting } from './cli/command-inference.js';
|
||||
import { handleConfigCli } from './cli/config-command.js';
|
||||
import { handleDaemonCli } from './cli/daemon-command.js';
|
||||
import { handleEmitTs } from './cli/emit-ts-command.js';
|
||||
import { CliUsageError } from './cli/errors.js';
|
||||
import { handleGenerateCli } from './cli/generate-cli-runner.js';
|
||||
import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js';
|
||||
import { handleInspectCli } from './cli/inspect-cli-command.js';
|
||||
import { handleList, printListHelp } from './cli/list-command.js';
|
||||
import { logError, logInfo } from './cli/logger-context.js';
|
||||
import { isRecordReplayModeActive, isReplayModeActive } from './cli/record-replay-env.js';
|
||||
import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
|
||||
import { resolveConfigPath } from './config.js';
|
||||
import { DaemonClient } from './daemon/client.js';
|
||||
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
|
||||
import { isKeepAliveServer } from './lifecycle.js';
|
||||
import { createRuntime } from './runtime.js';
|
||||
import { resolveConfigPath } from './config/path-discovery.js';
|
||||
import type { Runtime, RuntimeOptions } from './runtime.js';
|
||||
|
||||
export { handleAuth, printAuthHelp } from './cli/auth-command.js';
|
||||
export { parseCallArguments } from './cli/call-arguments.js';
|
||||
export { handleCall } from './cli/call-command.js';
|
||||
export { handleGenerateCli } from './cli/generate-cli-runner.js';
|
||||
export { handleInspectCli } from './cli/inspect-cli-command.js';
|
||||
export { extractListFlags, handleList } from './cli/list-command.js';
|
||||
export { extractListFlags } from './cli/list-flags.js';
|
||||
export { resolveCallTimeout } from './cli/timeouts.js';
|
||||
|
||||
const FORCE_EXIT_GRACE_MS = 50;
|
||||
const DAEMON_FAST_PATH_SERVERS = new Set(['chrome-devtools', 'mobile-mcp', 'playwright']);
|
||||
|
||||
export async function handleAuth(
|
||||
...args: Parameters<typeof import('./cli/auth-command.js').handleAuth>
|
||||
): ReturnType<typeof import('./cli/auth-command.js').handleAuth> {
|
||||
const { handleAuth: imported } = await import('./cli/auth-command.js');
|
||||
return imported(...args);
|
||||
}
|
||||
|
||||
export async function printAuthHelp(): Promise<void> {
|
||||
const { printAuthHelp: imported } = await import('./cli/auth-command.js');
|
||||
imported();
|
||||
}
|
||||
|
||||
export async function handleCall(
|
||||
...args: Parameters<typeof import('./cli/call-command.js').handleCall>
|
||||
): ReturnType<typeof import('./cli/call-command.js').handleCall> {
|
||||
const { handleCall: imported } = await import('./cli/call-command.js');
|
||||
return imported(...args);
|
||||
}
|
||||
|
||||
export async function handleGenerateCli(
|
||||
...args: Parameters<typeof import('./cli/generate-cli-runner.js').handleGenerateCli>
|
||||
): ReturnType<typeof import('./cli/generate-cli-runner.js').handleGenerateCli> {
|
||||
const { handleGenerateCli: imported } = await import('./cli/generate-cli-runner.js');
|
||||
return imported(...args);
|
||||
}
|
||||
|
||||
export async function handleInspectCli(
|
||||
...args: Parameters<typeof import('./cli/inspect-cli-command.js').handleInspectCli>
|
||||
): ReturnType<typeof import('./cli/inspect-cli-command.js').handleInspectCli> {
|
||||
const { handleInspectCli: imported } = await import('./cli/inspect-cli-command.js');
|
||||
return imported(...args);
|
||||
}
|
||||
|
||||
export async function handleList(
|
||||
...args: Parameters<typeof import('./cli/list-command.js').handleList>
|
||||
): ReturnType<typeof import('./cli/list-command.js').handleList> {
|
||||
const { handleList: imported } = await import('./cli/list-command.js');
|
||||
return imported(...args);
|
||||
}
|
||||
|
||||
export async function handleResource(
|
||||
...args: Parameters<typeof import('./cli/resource-command.js').handleResource>
|
||||
): ReturnType<typeof import('./cli/resource-command.js').handleResource> {
|
||||
const { handleResource: imported } = await import('./cli/resource-command.js');
|
||||
return imported(...args);
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[]): Promise<void> {
|
||||
const args = [...argv];
|
||||
if (args.length === 0) {
|
||||
@ -62,11 +98,25 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
|
||||
// Early-exit command handlers that don't require runtime inference.
|
||||
if (command === 'generate-cli') {
|
||||
await handleGenerateCli(args, globalFlags);
|
||||
if (consumeHelpTokens(args)) {
|
||||
const { printGenerateCliHelp } = await import('./cli/generate-cli-runner.js');
|
||||
printGenerateCliHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const { handleGenerateCli: importedHandleGenerateCli } = await import('./cli/generate-cli-runner.js');
|
||||
await importedHandleGenerateCli(args, globalFlags);
|
||||
return;
|
||||
}
|
||||
if (command === 'inspect-cli') {
|
||||
await handleInspectCli(args);
|
||||
if (consumeHelpTokens(args)) {
|
||||
const { printInspectCliHelp } = await import('./cli/inspect-cli-command.js');
|
||||
printInspectCliHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const { handleInspectCli: importedHandleInspectCli } = await import('./cli/inspect-cli-command.js');
|
||||
await importedHandleInspectCli(args);
|
||||
return;
|
||||
}
|
||||
const rootOverride = globalFlags['--root'];
|
||||
@ -81,6 +131,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
};
|
||||
|
||||
if (command === 'daemon') {
|
||||
const { handleDaemonCli } = await import('./cli/daemon-command.js');
|
||||
await handleDaemonCli(args, {
|
||||
configPath: configPathResolved,
|
||||
configExplicit: configResolution.explicit,
|
||||
@ -89,7 +140,45 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
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') {
|
||||
const { handleConfigCli } = await import('./cli/config-command.js');
|
||||
await handleConfigCli(
|
||||
{
|
||||
loadOptions: { configPath, rootDir: rootOverride },
|
||||
@ -101,6 +190,16 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (command === 'emit-ts') {
|
||||
if (consumeHelpTokens(args)) {
|
||||
const { printEmitTsHelp } = await import('./cli/emit-ts-command.js');
|
||||
printEmitTsHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const [{ createRuntime }, { handleEmitTs }] = await Promise.all([
|
||||
import('./runtime.js'),
|
||||
import('./cli/emit-ts-command.js'),
|
||||
]);
|
||||
const runtime = await createRuntime(runtimeOptionsWithPath);
|
||||
try {
|
||||
await handleEmitTs(runtime, args);
|
||||
@ -110,15 +209,28 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await maybeHandleDaemonFastCall(command, args, configResolution, rootOverride)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ createRuntime }, { DaemonClient }, { createKeepAliveRuntime }, { isKeepAliveServer }] = await Promise.all([
|
||||
import('./runtime.js'),
|
||||
import('./daemon/client.js'),
|
||||
import('./daemon/runtime-wrapper.js'),
|
||||
import('./lifecycle.js'),
|
||||
]);
|
||||
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
|
||||
const keepAliveServers = new Set(
|
||||
baseRuntime
|
||||
.getDefinitions()
|
||||
.filter(isKeepAliveServer)
|
||||
.map((entry) => entry.name)
|
||||
);
|
||||
const recordReplayModeActive = isRecordReplayModeActive();
|
||||
const keepAliveServers = recordReplayModeActive
|
||||
? new Set<string>()
|
||||
: new Set(
|
||||
baseRuntime
|
||||
.getDefinitions()
|
||||
.filter(isKeepAliveServer)
|
||||
.map((entry) => entry.name)
|
||||
);
|
||||
const daemonClient =
|
||||
keepAliveServers.size > 0
|
||||
!recordReplayModeActive && keepAliveServers.size > 0
|
||||
? new DaemonClient({
|
||||
configPath: configResolution.path,
|
||||
configExplicit: configResolution.explicit,
|
||||
@ -127,83 +239,138 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
: null;
|
||||
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
|
||||
|
||||
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
||||
if (inference.kind === 'abort') {
|
||||
process.exitCode = inference.exitCode;
|
||||
return;
|
||||
}
|
||||
const resolvedCommand = inference.command;
|
||||
const resolvedArgs = inference.args;
|
||||
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
||||
if (inference.kind === 'abort') {
|
||||
process.exitCode = inference.exitCode;
|
||||
return;
|
||||
}
|
||||
const resolvedCommand = inference.command;
|
||||
const resolvedArgs = inference.args;
|
||||
|
||||
if (resolvedCommand === 'list') {
|
||||
if (consumeHelpTokens(resolvedArgs)) {
|
||||
const { printListHelp } = await import('./cli/list-command.js');
|
||||
printListHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
await handleList(runtime, resolvedArgs);
|
||||
const { handleList: importedHandleList } = await import('./cli/list-command.js');
|
||||
await importedHandleList(runtime, resolvedArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedCommand === 'call') {
|
||||
if (consumeHelpTokens(resolvedArgs)) {
|
||||
const { printCallHelp } = await import('./cli/call-command.js');
|
||||
printCallHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const { handleCall: runHandleCall } = await import('./cli/call-command.js');
|
||||
await runHandleCall(runtime, resolvedArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedCommand === 'auth') {
|
||||
if (consumeHelpTokens(resolvedArgs)) {
|
||||
printAuthHelp();
|
||||
const { printAuthHelp: importedPrintAuthHelp } = await import('./cli/auth-command.js');
|
||||
importedPrintAuthHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
await handleAuth(runtime, resolvedArgs);
|
||||
const { handleAuth: importedHandleAuth } = await import('./cli/auth-command.js');
|
||||
await importedHandleAuth(runtime, resolvedArgs);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
const closeStart = Date.now();
|
||||
if (DEBUG_HANG) {
|
||||
logInfo('[debug] beginning runtime.close()');
|
||||
dumpActiveHandles('before runtime.close');
|
||||
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await runtime.close();
|
||||
if (DEBUG_HANG) {
|
||||
const duration = Date.now() - closeStart;
|
||||
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
|
||||
dumpActiveHandles('after runtime.close');
|
||||
|
||||
if (resolvedCommand === 'resource' || resolvedCommand === 'resources') {
|
||||
if (consumeHelpTokens(resolvedArgs)) {
|
||||
const { printResourceHelp } = await import('./cli/resource-command.js');
|
||||
printResourceHelp();
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG_HANG) {
|
||||
logError('[debug] runtime.close() failed', error);
|
||||
}
|
||||
} finally {
|
||||
terminateChildProcesses('runtime.finally');
|
||||
// By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles
|
||||
// (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1.
|
||||
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
|
||||
if (DEBUG_HANG) {
|
||||
dumpActiveHandles('after terminateChildProcesses');
|
||||
if (!disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1') {
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
const scheduleExit = () => {
|
||||
if (!disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1') {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
setImmediate(scheduleExit);
|
||||
const { handleResource: importedHandleResource } = await import('./cli/resource-command.js');
|
||||
await importedHandleResource(runtime, resolvedArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
printHelp(`Unknown command '${resolvedCommand}'.`);
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
|
||||
}
|
||||
}
|
||||
|
||||
async function closeRuntimeAfterCommand(
|
||||
runtime: Runtime,
|
||||
options: { readonly suppressReplayCloseError?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const closeStart = Date.now();
|
||||
let closeError: unknown;
|
||||
if (DEBUG_HANG) {
|
||||
logInfo('[debug] beginning runtime.close()');
|
||||
dumpActiveHandles('before runtime.close');
|
||||
}
|
||||
try {
|
||||
await runtime.close();
|
||||
if (DEBUG_HANG) {
|
||||
const duration = Date.now() - closeStart;
|
||||
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
|
||||
dumpActiveHandles('after runtime.close');
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG_HANG) {
|
||||
logError('[debug] runtime.close() failed', error);
|
||||
}
|
||||
if (isReplayModeActive() && !options.suppressReplayCloseError) {
|
||||
closeError = error;
|
||||
}
|
||||
} finally {
|
||||
terminateChildProcesses('runtime.finally');
|
||||
// By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles
|
||||
// (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1.
|
||||
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
|
||||
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
|
||||
const scheduleForcedExit = () => {
|
||||
if (shouldForceExit) {
|
||||
setTimeout(() => {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, FORCE_EXIT_GRACE_MS);
|
||||
}
|
||||
};
|
||||
if (DEBUG_HANG) {
|
||||
dumpActiveHandles('after terminateChildProcesses');
|
||||
scheduleForcedExit();
|
||||
} else {
|
||||
setImmediate(scheduleForcedExit);
|
||||
}
|
||||
}
|
||||
printHelp(`Unknown command '${resolvedCommand}'.`);
|
||||
process.exit(1);
|
||||
if (closeError) {
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapperArgsBeforeSeparator(args: readonly string[]): string[] {
|
||||
const separatorIndex = args.indexOf('--');
|
||||
return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
// main parses CLI flags and dispatches to list/call commands.
|
||||
@ -224,11 +391,237 @@ if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeAuthCommand(runtimeOptions: Parameters<typeof createRuntime>[0], args: string[]): Promise<void> {
|
||||
async function invokeAuthCommand(runtimeOptions: RuntimeOptions, args: string[]): Promise<void> {
|
||||
const [{ createRuntime }, { handleAuth: importedHandleAuth }] = await Promise.all([
|
||||
import('./runtime.js'),
|
||||
import('./cli/auth-command.js'),
|
||||
]);
|
||||
const runtime = await createRuntime(runtimeOptions);
|
||||
try {
|
||||
await handleAuth(runtime, args);
|
||||
await importedHandleAuth(runtime, args);
|
||||
} finally {
|
||||
await runtime.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeHandleDaemonFastCall(
|
||||
command: string,
|
||||
args: string[],
|
||||
configResolution: { path: string; explicit: boolean },
|
||||
rootDir: string | undefined
|
||||
): Promise<boolean> {
|
||||
if (isRecordReplayModeActive()) {
|
||||
return false;
|
||||
}
|
||||
const callArgs = resolveDaemonFastCallArgs(command, args);
|
||||
if (!callArgs) {
|
||||
return false;
|
||||
}
|
||||
const server = resolveExplicitCallServer(callArgs);
|
||||
if (!server || !DAEMON_FAST_PATH_SERVERS.has(server) || isFastPathKeepAliveDisabled(server)) {
|
||||
return false;
|
||||
}
|
||||
if (await maybeHandleSimpleDaemonFastCall(callArgs, configResolution, rootDir)) {
|
||||
return true;
|
||||
}
|
||||
const [{ DaemonClient }, { handleCall: importedHandleCall }] = await Promise.all([
|
||||
import('./daemon/client.js'),
|
||||
import('./cli/call-command.js'),
|
||||
]);
|
||||
const daemonClient = new DaemonClient({
|
||||
configPath: configResolution.path,
|
||||
configExplicit: configResolution.explicit,
|
||||
rootDir,
|
||||
});
|
||||
await importedHandleCall(createDaemonOnlyRuntime(daemonClient), callArgs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function maybeHandleSimpleDaemonFastCall(
|
||||
callArgs: string[],
|
||||
configResolution: { path: string; explicit: boolean },
|
||||
rootDir: string | undefined
|
||||
): Promise<boolean> {
|
||||
const [{ parseCallArguments }, { resolveCallTimeout }] = await Promise.all([
|
||||
import('./cli/call-arguments.js'),
|
||||
import('./cli/timeouts.js'),
|
||||
]);
|
||||
let parsed: ReturnType<typeof parseCallArguments>;
|
||||
try {
|
||||
parsed = parseCallArguments([...callArgs]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!parsed.server ||
|
||||
!parsed.tool ||
|
||||
parsed.ephemeral ||
|
||||
parsed.tailLog ||
|
||||
parsed.saveImagesDir ||
|
||||
(parsed.positionalArgs?.length ?? 0) > 0 ||
|
||||
parsed.schemaStringCoercionCandidates ||
|
||||
parsed.schemaArrayCoercionCandidates
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [{ DaemonClient }, { wrapCallResult }, { printCallOutput }] = await Promise.all([
|
||||
import('./daemon/client.js'),
|
||||
import('./result-utils.js'),
|
||||
import('./cli/output-utils.js'),
|
||||
]);
|
||||
const daemonClient = new DaemonClient({
|
||||
configPath: configResolution.path,
|
||||
configExplicit: configResolution.explicit,
|
||||
rootDir,
|
||||
});
|
||||
const result = await daemonClient.callTool({
|
||||
server: parsed.server,
|
||||
tool: parsed.tool,
|
||||
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
|
||||
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
|
||||
disableOAuth: parsed.disableOAuth,
|
||||
});
|
||||
const { callResult } = wrapCallResult(result);
|
||||
printCallOutput(callResult, result, parsed.output);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveDaemonFastCallArgs(command: string, args: string[]): string[] | undefined {
|
||||
if (command === 'call') {
|
||||
return args;
|
||||
}
|
||||
if (isExplicitNonCallCommand(command) || command.includes('://')) {
|
||||
return undefined;
|
||||
}
|
||||
if (!/[.(]/.test(command)) {
|
||||
return undefined;
|
||||
}
|
||||
return [command, ...args];
|
||||
}
|
||||
|
||||
function isExplicitNonCallCommand(command: string): boolean {
|
||||
return (
|
||||
command === 'list' ||
|
||||
command === 'auth' ||
|
||||
command === 'resource' ||
|
||||
command === 'resources' ||
|
||||
command === 'daemon' ||
|
||||
command === 'serve' ||
|
||||
command === 'record' ||
|
||||
command === 'replay' ||
|
||||
command === 'config' ||
|
||||
command === 'emit-ts' ||
|
||||
command === 'generate-cli' ||
|
||||
command === 'inspect-cli' ||
|
||||
command === 'describe'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExplicitCallServer(args: readonly string[]): string | undefined {
|
||||
let serverFlag: string | undefined;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const token = args[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (isHelpToken(token)) {
|
||||
return undefined;
|
||||
}
|
||||
if (token === '--http-url' || token === '--stdio') {
|
||||
return undefined;
|
||||
}
|
||||
if (token === '--server') {
|
||||
serverFlag = args[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--server=')) {
|
||||
serverFlag = token.slice('--server='.length);
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
if (token.includes('://')) {
|
||||
return undefined;
|
||||
}
|
||||
const separator = token.indexOf('.');
|
||||
if (separator > 0) {
|
||||
return token.slice(0, separator);
|
||||
}
|
||||
return serverFlag;
|
||||
}
|
||||
return serverFlag;
|
||||
}
|
||||
|
||||
function isFastPathKeepAliveDisabled(server: string): boolean {
|
||||
const raw = process.env.MCPORTER_DISABLE_KEEPALIVE ?? process.env.MCPORTER_NO_KEEPALIVE;
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const disabled = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
return disabled.has('*') || disabled.has(server.toLowerCase());
|
||||
}
|
||||
|
||||
function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').DaemonClient): Runtime {
|
||||
return {
|
||||
listServers: () => [],
|
||||
getDefinitions: () => [],
|
||||
getDefinition: (server: string) => {
|
||||
throw new Error(`Server '${server}' is only available through the keep-alive daemon fast path.`);
|
||||
},
|
||||
registerDefinition: () => {
|
||||
throw new Error('Ad-hoc servers are not supported by the keep-alive daemon fast path.');
|
||||
},
|
||||
getInstructions: async () => undefined,
|
||||
listTools: async (server, options) =>
|
||||
(await daemonClient.listTools({
|
||||
server,
|
||||
includeSchema: options?.includeSchema,
|
||||
autoAuthorize: options?.autoAuthorize,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})) as Awaited<ReturnType<Runtime['listTools']>>,
|
||||
callTool: (server, toolName, options) =>
|
||||
daemonClient.callTool({
|
||||
server,
|
||||
tool: toolName,
|
||||
args: options?.args,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
}),
|
||||
listResources: (server, options) => {
|
||||
const params: Record<string, unknown> = { ...options };
|
||||
delete params.allowCachedAuth;
|
||||
delete params.disableOAuth;
|
||||
delete params.oauthSessionOptions;
|
||||
return daemonClient.listResources({
|
||||
server,
|
||||
params,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
});
|
||||
},
|
||||
readResource: (server, uri, options) =>
|
||||
daemonClient.readResource({
|
||||
server,
|
||||
uri,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
}),
|
||||
connect: async (server) => {
|
||||
throw new Error(`Server '${server}' is only available through daemon request methods.`);
|
||||
},
|
||||
close: async (server?: string) => {
|
||||
if (server) {
|
||||
await daemonClient.closeServer({ server }).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import type { CommandSpec, ServerDefinition } from '../config.js';
|
||||
import { __configInternals } from '../config.js';
|
||||
import { expandHome } from '../env.js';
|
||||
import { withFileLock, writeTextFileAtomic } from '../fs-json.js';
|
||||
import { canonicalKeepAliveName, resolveLifecycle } from '../lifecycle.js';
|
||||
|
||||
export interface EphemeralServerSpec {
|
||||
@ -13,6 +14,7 @@ export interface EphemeralServerSpec {
|
||||
stdioArgs?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
description?: string;
|
||||
persistPath?: string;
|
||||
}
|
||||
@ -44,10 +46,10 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
const command: CommandSpec = {
|
||||
kind: 'http',
|
||||
url,
|
||||
headers: __configInternals.ensureHttpAcceptHeader(undefined),
|
||||
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
|
||||
};
|
||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
||||
const name = slugify(spec.name ?? canonical ?? inferNameFromUrl(url));
|
||||
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromUrl(url));
|
||||
const lifecycle = resolveLifecycle(name, undefined, command);
|
||||
const definition: ServerDefinition = {
|
||||
name,
|
||||
@ -61,6 +63,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
baseUrl: url.href,
|
||||
...(spec.description ? { description: spec.description } : {}),
|
||||
...(spec.env && Object.keys(spec.env).length > 0 ? { env: spec.env } : {}),
|
||||
...(spec.headers && Object.keys(spec.headers).length > 0 ? { headers: spec.headers } : {}),
|
||||
...(lifecycle ? { lifecycle: serializeLifecycle(lifecycle) } : {}),
|
||||
};
|
||||
return { definition, name, persistedEntry };
|
||||
@ -81,7 +84,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
cwd,
|
||||
};
|
||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
||||
const name = slugify(spec.name ?? canonical ?? inferNameFromCommand(parts));
|
||||
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromCommand(parts));
|
||||
const lifecycle = resolveLifecycle(name, undefined, command);
|
||||
const definition: ServerDefinition = {
|
||||
name,
|
||||
@ -106,26 +109,27 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
|
||||
export async function persistEphemeralServer(resolution: EphemeralServerResolution, rawPath: string): Promise<void> {
|
||||
const resolvedPath = path.resolve(expandHome(rawPath));
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
const buffer = await fs.readFile(resolvedPath, 'utf8');
|
||||
existing = JSON.parse(buffer) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
await withFileLock(resolvedPath, async () => {
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
const buffer = await fs.readFile(resolvedPath, 'utf8');
|
||||
existing = JSON.parse(buffer) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
existing = { mcpServers: {} };
|
||||
}
|
||||
existing = { mcpServers: {} };
|
||||
}
|
||||
|
||||
if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) {
|
||||
existing.mcpServers = {};
|
||||
}
|
||||
const servers = existing.mcpServers as Record<string, unknown>;
|
||||
servers[resolution.name] = resolution.persistedEntry;
|
||||
if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) {
|
||||
existing.mcpServers = {};
|
||||
}
|
||||
const servers = existing.mcpServers as Record<string, unknown>;
|
||||
servers[resolution.name] = resolution.persistedEntry;
|
||||
|
||||
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
||||
const serialized = `${JSON.stringify(existing, null, 2)}\n`;
|
||||
await fs.writeFile(resolvedPath, serialized, 'utf8');
|
||||
const serialized = `${JSON.stringify(existing, null, 2)}\n`;
|
||||
await writeTextFileAtomic(resolvedPath, serialized);
|
||||
});
|
||||
}
|
||||
|
||||
function inferNameFromUrl(url: URL): string {
|
||||
@ -202,6 +206,14 @@ function slugify(value: string): string {
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function normalizeEphemeralName(value: string): string {
|
||||
const name = slugify(value);
|
||||
if (!name) {
|
||||
throw new Error('Ad-hoc server name must contain at least one letter or digit.');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function splitCommandLine(input: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
|
||||
@ -1,19 +1,32 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ServerDefinition } from '../config-schema.js';
|
||||
import type { OAuthAuthorizationRequest, OAuthSessionOptions } from '../oauth.js';
|
||||
import { analyzeConnectionError } from '../error-classifier.js';
|
||||
import { clearOAuthCaches } from '../oauth-persistence.js';
|
||||
import type { createRuntime } from '../runtime.js';
|
||||
import { isOAuthFlowError } from '../runtime/oauth.js';
|
||||
import type { EphemeralServerSpec } from './adhoc-server.js';
|
||||
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
||||
import { prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||
import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||
import { looksLikeHttpUrl } from './http-utils.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';
|
||||
|
||||
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> {
|
||||
const browserSuppression = consumeBrowserSuppression(args, process.env);
|
||||
const noBrowser = browserSuppression === 'no-browser';
|
||||
let authorizationOutputEmitted = false;
|
||||
const markAuthorizationOutputEmitted = () => {
|
||||
authorizationOutputEmitted = true;
|
||||
};
|
||||
const resetIndex = args.indexOf('--reset');
|
||||
const shouldReset = resetIndex !== -1;
|
||||
if (shouldReset) {
|
||||
@ -48,35 +61,60 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
||||
const definition = runtime.getDefinition(target);
|
||||
if (shouldReset) {
|
||||
await clearOAuthCaches(definition);
|
||||
logInfo(`Cleared cached credentials for '${target}'.`);
|
||||
if (!noBrowser) {
|
||||
logInfo(`Cleared cached credentials for '${target}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.command.kind === 'stdio' && definition.oauthCommand) {
|
||||
logInfo(`Starting auth helper for '${target}' (stdio). Leave this running until the browser flow completes.`);
|
||||
await runStdioAuth(definition);
|
||||
logInfo(`Auth helper for '${target}' finished. You can now call tools.`);
|
||||
try {
|
||||
await runStdioAuth(definition, { noBrowser });
|
||||
logInfo(`Auth helper for '${target}' finished. You can now call tools.`);
|
||||
} finally {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
logInfo(`Initiating OAuth flow for '${target}'...`);
|
||||
const tools = await runtime.listTools(target, { autoAuthorize: true });
|
||||
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
|
||||
if (!noBrowser) {
|
||||
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);
|
||||
if (!noBrowser) {
|
||||
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
if (attempt === 0 && shouldRetryAuthError(error)) {
|
||||
logWarn('Server signaled OAuth after the initial attempt. Retrying with browser flow...');
|
||||
continue;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (format === 'json') {
|
||||
const payload = buildConnectionIssueEnvelope({
|
||||
server: target,
|
||||
error,
|
||||
issue: analyzeConnectionError(error),
|
||||
});
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
if (authorizationOutputEmitted) {
|
||||
console.error(`Failed to authorize '${target}': ${message}`);
|
||||
} else {
|
||||
const payload = buildConnectionIssueEnvelope({
|
||||
server: target,
|
||||
error,
|
||||
issue: analyzeConnectionError(error),
|
||||
});
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@ -85,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 ?? []) : [])];
|
||||
if (definition.oauthCommand) {
|
||||
authArgs.push(...definition.oauthCommand.args);
|
||||
}
|
||||
const env = options.noBrowser ? { ...process.env, MCPORTER_OAUTH_NO_BROWSER: '1' } : process.env;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(definition.command.kind === 'stdio' ? definition.command.command : '', authArgs, {
|
||||
stdio: 'inherit',
|
||||
cwd: definition.command.kind === 'stdio' ? definition.command.cwd : process.cwd(),
|
||||
env: process.env,
|
||||
env,
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
@ -107,7 +160,72 @@ 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 {
|
||||
if (isOAuthFlowError(error)) {
|
||||
return false;
|
||||
}
|
||||
return analyzeConnectionError(error).kind === 'auth';
|
||||
}
|
||||
|
||||
@ -120,11 +238,15 @@ export function printAuthHelp(): void {
|
||||
'',
|
||||
'Common flags:',
|
||||
' --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:',
|
||||
' --http-url <url> Register an HTTP server for this run.',
|
||||
' --allow-http Permit plain http:// URLs with --http-url.',
|
||||
' --header KEY=value Attach HTTP headers (repeatable).',
|
||||
' --stdio <command> Run a stdio MCP server (repeat --stdio-arg for args).',
|
||||
' --stdio-arg <value> Append args to the stdio command (repeatable).',
|
||||
' --env KEY=value Inject env vars for stdio servers (repeatable).',
|
||||
@ -136,6 +258,7 @@ export function printAuthHelp(): void {
|
||||
'',
|
||||
'Examples:',
|
||||
' mcporter auth linear',
|
||||
' mcporter auth linear --no-browser',
|
||||
' mcporter auth https://mcp.example.com/mcp',
|
||||
' mcporter auth --stdio "npx -y chrome-devtools-mcp@latest"',
|
||||
' mcporter auth --http-url http://localhost:3000/mcp --allow-http',
|
||||
|
||||
@ -51,9 +51,9 @@ function buildCallExpressionUsageError(error: unknown): CliUsageError {
|
||||
`Reason: ${reason}`,
|
||||
'',
|
||||
'Examples:',
|
||||
' mcporter \'context7.resolve-library-id(libraryName: "react")\'',
|
||||
' mcporter \'context7.resolve-library-id("react")\'',
|
||||
' mcporter context7.resolve-library-id libraryName=react',
|
||||
' mcporter \'context7.resolve-library-id(query: "React hooks docs", libraryName: "react")\'',
|
||||
' mcporter \'context7.resolve-library-id("React hooks docs", "react")\'',
|
||||
' mcporter context7.resolve-library-id query="React hooks docs" libraryName=react',
|
||||
'',
|
||||
'Tip: wrap the entire expression in single quotes so the shell preserves parentheses and commas.',
|
||||
];
|
||||
|
||||
@ -9,7 +9,8 @@ export interface ParsedKeyValueToken {
|
||||
export function parseKeyValueToken(token: string, nextToken: string | undefined): ParsedKeyValueToken | undefined {
|
||||
const eqIndex = token.indexOf('=');
|
||||
if (eqIndex !== -1) {
|
||||
const key = token.slice(0, eqIndex);
|
||||
const keyEnd = eqIndex > 0 && token[eqIndex - 1] === ':' ? eqIndex - 1 : eqIndex;
|
||||
const key = token.slice(0, keyEnd);
|
||||
const rawValue = token.slice(eqIndex + 1);
|
||||
if (!key) {
|
||||
return undefined;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs';
|
||||
import type { EphemeralServerSpec } from './adhoc-server.js';
|
||||
import { parseLeadingCallExpression } from './call-argument-expression.js';
|
||||
import {
|
||||
@ -19,10 +20,12 @@ export interface CallArgsParseResult {
|
||||
tool?: string;
|
||||
args: Record<string, unknown>;
|
||||
schemaStringCoercionCandidates?: Record<string, string>;
|
||||
schemaArrayCoercionCandidates?: Record<string, string>;
|
||||
positionalArgs?: unknown[];
|
||||
tailLog: boolean;
|
||||
output: OutputFormat;
|
||||
timeoutMs?: number;
|
||||
disableOAuth?: boolean;
|
||||
ephemeral?: EphemeralServerSpec;
|
||||
rawStrings?: boolean;
|
||||
saveImagesDir?: string;
|
||||
@ -57,11 +60,13 @@ const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
||||
'--tool': handleToolFlag,
|
||||
'--timeout': handleTimeoutFlag,
|
||||
'--tail-log': handleTailLogFlag,
|
||||
'--no-oauth': handleDisableOAuthFlag,
|
||||
'--save-images': handleSaveImagesFlag,
|
||||
'--yes': handleNoopFlag,
|
||||
'--raw-strings': handleRawStringsFlag,
|
||||
'--no-coerce': handleNoCoerceFlag,
|
||||
'--args': handleArgsFlag,
|
||||
'--json': handleJsonArgsFlag,
|
||||
};
|
||||
|
||||
export function parseCallArguments(args: string[]): CallArgsParseResult {
|
||||
@ -100,7 +105,8 @@ function scanCallTokens(args: string[], result: CallArgsParseResult, state: Flag
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--')) {
|
||||
throw new CliUsageError(buildUnknownCallFlagMessage(token));
|
||||
index = handleNamedArgumentFlag({ args, index, result, state });
|
||||
continue;
|
||||
}
|
||||
positional.push(token);
|
||||
index += 1;
|
||||
@ -187,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
||||
continue;
|
||||
}
|
||||
index += parsed.consumed;
|
||||
const value = coerceValue(parsed.rawValue, state.coercionMode);
|
||||
const { value, schemaValue } = resolveNamedArgumentValue(parsed.rawValue, state.coercionMode);
|
||||
if (parsed.key === 'tool' && !result.tool) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error("Argument 'tool' must be a string value.");
|
||||
@ -204,7 +210,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
||||
}
|
||||
if (state.coercionMode === 'default' && typeof value === 'number') {
|
||||
result.schemaStringCoercionCandidates ??= {};
|
||||
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
|
||||
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
|
||||
}
|
||||
result.args[parsed.key] = value;
|
||||
}
|
||||
@ -252,6 +258,11 @@ function handleTailLogFlag(context: FlagHandlerContext): number {
|
||||
return context.index + 1;
|
||||
}
|
||||
|
||||
function handleDisableOAuthFlag(context: FlagHandlerContext): number {
|
||||
context.result.disableOAuth = true;
|
||||
return context.index + 1;
|
||||
}
|
||||
|
||||
function handleSaveImagesFlag(context: FlagHandlerContext): number {
|
||||
context.result.saveImagesDir = consumeFlagValue(
|
||||
context.args,
|
||||
@ -279,20 +290,97 @@ function handleNoCoerceFlag(context: FlagHandlerContext): number {
|
||||
}
|
||||
|
||||
function handleArgsFlag(context: FlagHandlerContext): number {
|
||||
const raw = consumeFlagValue(context.args, context.index, '--args', '--args requires a JSON value.');
|
||||
return consumeJsonArgsFlag(context, '--args', '--args requires a JSON value.');
|
||||
}
|
||||
|
||||
function handleJsonArgsFlag(context: FlagHandlerContext): number {
|
||||
return consumeJsonArgsFlag(context, '--json', '--json requires a JSON object value.');
|
||||
}
|
||||
|
||||
function consumeJsonArgsFlag(context: FlagHandlerContext, flagName: string, missingValueMessage: string): number {
|
||||
const rawFlagValue = consumeFlagValue(context.args, context.index, flagName, missingValueMessage);
|
||||
const raw = rawFlagValue === '-' ? fs.readFileSync(0, 'utf8') : rawFlagValue;
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to parse --args: ${(error as Error).message}`, { cause: error });
|
||||
throw new Error(`Unable to parse ${flagName}: ${(error as Error).message}`, { cause: error });
|
||||
}
|
||||
if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
|
||||
throw new Error('Unable to parse --args: --args must be a JSON object.');
|
||||
throw new Error(`Unable to parse ${flagName}: ${flagName} must be a JSON object.`);
|
||||
}
|
||||
Object.assign(context.result.args, decoded);
|
||||
return context.index + 2;
|
||||
}
|
||||
|
||||
function handleNamedArgumentFlag(context: FlagHandlerContext): number {
|
||||
const token = context.args[context.index] ?? '';
|
||||
const body = token.slice(2);
|
||||
const eqIndex = body.indexOf('=');
|
||||
const rawKey = eqIndex === -1 ? body : body.slice(0, eqIndex);
|
||||
const key = normalizeLongFlagArgumentKey(rawKey);
|
||||
if (!key) {
|
||||
throw new CliUsageError(buildUnknownCallFlagMessage(token));
|
||||
}
|
||||
|
||||
const rawValue =
|
||||
eqIndex === -1
|
||||
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
||||
: body.slice(eqIndex + 1);
|
||||
const { value, schemaValue } = resolveNamedArgumentValue(rawValue, context.state.coercionMode);
|
||||
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
||||
context.result.schemaStringCoercionCandidates ??= {};
|
||||
context.result.schemaStringCoercionCandidates[key] = schemaValue;
|
||||
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
||||
context.result.schemaArrayCoercionCandidates ??= {};
|
||||
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
|
||||
}
|
||||
context.result.args[key] = value;
|
||||
return context.index + (eqIndex === -1 ? 2 : 1);
|
||||
}
|
||||
|
||||
function resolveNamedArgumentValue(
|
||||
rawValue: string,
|
||||
coercionMode: CoercionMode
|
||||
): { value: unknown; schemaValue: string } {
|
||||
if (rawValue.startsWith('@@')) {
|
||||
const literal = rawValue.slice(1);
|
||||
return { value: literal, schemaValue: literal };
|
||||
}
|
||||
if (rawValue.length > 0 && rawValue.trim() === '') {
|
||||
return { value: rawValue, schemaValue: rawValue };
|
||||
}
|
||||
if (!rawValue.startsWith('@')) {
|
||||
return { value: coerceValue(rawValue, coercionMode), schemaValue: rawValue };
|
||||
}
|
||||
|
||||
const filePath = rawValue.slice(1);
|
||||
if (!filePath) {
|
||||
throw new CliUsageError("Argument file reference '@' requires a path. Use '@@' for a literal leading '@'.");
|
||||
}
|
||||
|
||||
let contents: Buffer;
|
||||
try {
|
||||
contents = fs.readFileSync(filePath);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CliUsageError(`Unable to read argument file '${filePath}': ${detail}`);
|
||||
}
|
||||
try {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(contents);
|
||||
return { value: text, schemaValue: text };
|
||||
} catch {
|
||||
throw new CliUsageError(`Argument file '${filePath}' is not valid UTF-8 text.`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
||||
if (!rawKey || rawKey.startsWith('-')) {
|
||||
return '';
|
||||
}
|
||||
return rawKey.replace(/-([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function consumeFlagValue(args: string[], index: number, token: string, missingValueMessage?: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value) {
|
||||
|
||||
@ -7,7 +7,11 @@ import {
|
||||
CALL_HELP_EXAMPLE_LINES,
|
||||
CALL_HELP_RUNTIME_FLAG_LINES,
|
||||
} from './call-help.js';
|
||||
import { prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||
import {
|
||||
persistPreparedEphemeralServer,
|
||||
prepareEphemeralServerTarget,
|
||||
type PrepareEphemeralServerTargetResult,
|
||||
} from './ephemeral-target.js';
|
||||
import { looksLikeHttpUrl, normalizeHttpUrlCandidate } from './http-utils.js';
|
||||
import type { IdentifierResolution } from './identifier-helpers.js';
|
||||
import {
|
||||
@ -17,7 +21,6 @@ import {
|
||||
} from './identifier-helpers.js';
|
||||
import { saveCallImagesIfRequested } from './image-output.js';
|
||||
import { buildConnectionIssueEnvelope } from './json-output.js';
|
||||
import { handleList } from './list-command.js';
|
||||
import type { OutputFormat } from './output-utils.js';
|
||||
import { printCallOutput, tailLogIfRequested } from './output-utils.js';
|
||||
import { dumpActiveHandles } from './runtime-debug.js';
|
||||
@ -36,45 +39,72 @@ interface PreparedCallRequest extends ResolvedCallTarget {
|
||||
parsed: CallArgsParseResult;
|
||||
hydratedArgs: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
disableOAuth?: boolean;
|
||||
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
||||
}
|
||||
|
||||
export async function handleCall(runtime: Runtime, args: string[]): Promise<void> {
|
||||
const prepared = await prepareCallRequest(runtime, args);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
let prepared: PreparedCallRequest | undefined;
|
||||
try {
|
||||
prepared = await prepareCallRequest(runtime, args);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invocation = await invokePreparedCall(runtime, prepared);
|
||||
if (!invocation) {
|
||||
return;
|
||||
}
|
||||
const invocation = await invokePreparedCall(runtime, prepared);
|
||||
if (!invocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderCallResult(invocation.result, prepared.parsed);
|
||||
renderCallResult(invocation.result, prepared.parsed);
|
||||
} finally {
|
||||
await persistPreparedEphemeralServer(runtime, prepared?.ephemeralTarget);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<PreparedCallRequest | undefined> {
|
||||
const parsed = parseCallArguments(args);
|
||||
await normalizeParsedCallArguments(runtime, parsed);
|
||||
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
|
||||
const { server, tool } = await resolveServerAndTool(runtime, parsed);
|
||||
|
||||
if (await maybeDescribeServer(runtime, server, tool, parsed.output)) {
|
||||
if (await maybeDescribeServer(runtime, server, tool, parsed.output, parsed.disableOAuth)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
|
||||
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
|
||||
const schemaAwareArgs = await enforceSchemaStringTypes(
|
||||
const hydratedArgs = await hydratePositionalArguments(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
parsed.args,
|
||||
parsed.positionalArgs,
|
||||
parsed.disableOAuth
|
||||
);
|
||||
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
hydratedArgs,
|
||||
parsed.schemaStringCoercionCandidates,
|
||||
timeoutMs
|
||||
parsed.schemaArrayCoercionCandidates,
|
||||
timeoutMs,
|
||||
parsed.disableOAuth
|
||||
);
|
||||
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs };
|
||||
return {
|
||||
parsed,
|
||||
server,
|
||||
tool,
|
||||
hydratedArgs: schemaAwareArgs,
|
||||
timeoutMs,
|
||||
disableOAuth: parsed.disableOAuth,
|
||||
ephemeralTarget,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsParseResult): Promise<void> {
|
||||
async function normalizeParsedCallArguments(
|
||||
runtime: Runtime,
|
||||
parsed: CallArgsParseResult
|
||||
): Promise<PrepareEphemeralServerTargetResult> {
|
||||
let ephemeralSpec = parsed.ephemeral ? { ...parsed.ephemeral } : undefined;
|
||||
const nameHints: string[] = [];
|
||||
const absorbUrlCandidate = (value: string | undefined): string | undefined => {
|
||||
@ -121,6 +151,7 @@ async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsPa
|
||||
if (!parsed.selector) {
|
||||
parsed.selector = prepared.target;
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResult): Promise<ResolvedCallTarget> {
|
||||
@ -131,7 +162,7 @@ async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResul
|
||||
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
|
||||
}
|
||||
if (!tool) {
|
||||
tool = await inferSingleToolName(runtime, server);
|
||||
tool = await inferSingleToolName(runtime, server, parsed.disableOAuth);
|
||||
if (!tool) {
|
||||
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
||||
}
|
||||
@ -150,7 +181,9 @@ async function invokePreparedCall(
|
||||
prepared.server,
|
||||
prepared.tool,
|
||||
prepared.hydratedArgs,
|
||||
prepared.timeoutMs
|
||||
prepared.timeoutMs,
|
||||
prepared.parsed.output,
|
||||
prepared.disableOAuth
|
||||
);
|
||||
} catch (error) {
|
||||
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
||||
@ -167,12 +200,19 @@ async function invokePreparedCall(
|
||||
|
||||
function renderCallResult(result: unknown, parsed: CallArgsParseResult): void {
|
||||
const { callResult: wrapped } = wrapCallResult(result);
|
||||
if (isErrorCallResult(result)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
printCallOutput(wrapped, result, parsed.output);
|
||||
saveCallImagesIfRequested(wrapped, parsed.saveImagesDir);
|
||||
tailLogIfRequested(result, parsed.tailLog);
|
||||
dumpActiveHandles('after call (formatted result)');
|
||||
}
|
||||
|
||||
function isErrorCallResult(result: unknown): boolean {
|
||||
return !!result && typeof result === 'object' && (result as { isError?: unknown }).isError === true;
|
||||
}
|
||||
|
||||
export function printCallHelp(): void {
|
||||
const lines = [
|
||||
'Usage: mcporter call <server.tool | url> [arguments] [flags]',
|
||||
@ -202,21 +242,28 @@ async function maybeDescribeServer(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
tool: string,
|
||||
outputFormat: OutputFormat
|
||||
outputFormat: OutputFormat,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<boolean> {
|
||||
if (tool === 'list_tools') {
|
||||
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
|
||||
const listArgs = [server];
|
||||
if (disableOAuth) {
|
||||
listArgs.push('--no-oauth');
|
||||
}
|
||||
if (outputFormat === 'json') {
|
||||
listArgs.push('--json');
|
||||
}
|
||||
const { handleList } = await import('./list-command.js');
|
||||
await handleList(runtime, listArgs);
|
||||
return true;
|
||||
}
|
||||
if (tool !== 'help') {
|
||||
return false;
|
||||
}
|
||||
const tools = await runtime.listTools(server, { includeSchema: false, autoAuthorize: false }).catch(() => undefined);
|
||||
const tools = await runtime
|
||||
.listTools(server, { includeSchema: false, autoAuthorize: false, disableOAuth })
|
||||
.catch(() => undefined);
|
||||
if (!tools) {
|
||||
return false;
|
||||
}
|
||||
@ -226,9 +273,13 @@ async function maybeDescribeServer(
|
||||
}
|
||||
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
|
||||
const listArgs = [server];
|
||||
if (disableOAuth) {
|
||||
listArgs.push('--no-oauth');
|
||||
}
|
||||
if (outputFormat === 'json') {
|
||||
listArgs.push('--json');
|
||||
}
|
||||
const { handleList } = await import('./list-command.js');
|
||||
await handleList(runtime, listArgs);
|
||||
return true;
|
||||
}
|
||||
@ -265,21 +316,27 @@ function resolveCallTarget(
|
||||
return { server, tool };
|
||||
}
|
||||
|
||||
async function enforceSchemaStringTypes(
|
||||
async function enforceSchemaAwareArgumentTypes(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
rawCandidates: Record<string, string> | undefined,
|
||||
timeoutMs: number
|
||||
stringCandidates: Record<string, string> | undefined,
|
||||
arrayCandidates: Record<string, string> | undefined,
|
||||
timeoutMs: number,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!rawCandidates || Object.keys(rawCandidates).length === 0) {
|
||||
if (
|
||||
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
||||
(!arrayCandidates || Object.keys(arrayCandidates).length === 0)
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const tools = await withTimeout(loadToolMetadata(runtime, server, { includeSchema: true }), timeoutMs).catch(
|
||||
() => undefined
|
||||
);
|
||||
const tools = await withTimeout(
|
||||
loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }),
|
||||
timeoutMs
|
||||
).catch(() => undefined);
|
||||
if (!tools) {
|
||||
return args;
|
||||
}
|
||||
@ -290,7 +347,7 @@ async function enforceSchemaStringTypes(
|
||||
}
|
||||
|
||||
let corrected: Record<string, unknown> | undefined;
|
||||
for (const [key, rawValue] of Object.entries(rawCandidates)) {
|
||||
for (const [key, rawValue] of Object.entries(stringCandidates ?? {})) {
|
||||
if (typeof args[key] !== 'number') {
|
||||
continue;
|
||||
}
|
||||
@ -300,6 +357,17 @@ async function enforceSchemaStringTypes(
|
||||
corrected ??= { ...args };
|
||||
corrected[key] = rawValue;
|
||||
}
|
||||
for (const [key, rawValue] of Object.entries(arrayCandidates ?? {})) {
|
||||
if (typeof args[key] !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const descriptor = schema.properties[key];
|
||||
if (!schemaAllowsArray(descriptor) || schemaAllowsString(descriptor)) {
|
||||
continue;
|
||||
}
|
||||
corrected ??= { ...args };
|
||||
corrected[key] = [rawValue];
|
||||
}
|
||||
return corrected ?? args;
|
||||
}
|
||||
|
||||
@ -324,19 +392,41 @@ function schemaAllowsString(descriptor: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function schemaAllowsArray(descriptor: unknown): boolean {
|
||||
if (!descriptor || typeof descriptor !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = descriptor as Record<string, unknown>;
|
||||
const type = record.type;
|
||||
if (type === 'array') {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(type) && type.includes('array')) {
|
||||
return true;
|
||||
}
|
||||
for (const key of ['anyOf', 'oneOf', 'allOf'] as const) {
|
||||
const variants = record[key];
|
||||
if (Array.isArray(variants) && variants.some(schemaAllowsArray)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hydratePositionalArguments(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
tool: string,
|
||||
namedArgs: Record<string, unknown>,
|
||||
positionalArgs: unknown[] | undefined
|
||||
positionalArgs: unknown[] | undefined,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!positionalArgs || positionalArgs.length === 0) {
|
||||
return namedArgs;
|
||||
}
|
||||
// We need the schema order to know which field each positional argument maps to; pull the
|
||||
// tool list with schemas instead of guessing locally so optional/required order stays correct.
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true }).catch(() => undefined);
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }).catch(() => undefined);
|
||||
if (!tools) {
|
||||
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
||||
}
|
||||
@ -376,9 +466,10 @@ type ToolResolution = IdentifierResolution;
|
||||
|
||||
async function inferSingleToolName(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string
|
||||
server: string,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<string | undefined> {
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth });
|
||||
if (tools.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
@ -395,10 +486,12 @@ async function invokeWithAutoCorrection(
|
||||
server: string,
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||
// Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match.
|
||||
return attemptCall(runtime, server, tool, args, timeoutMs, true);
|
||||
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true, disableOAuth);
|
||||
}
|
||||
|
||||
async function attemptCall(
|
||||
@ -407,10 +500,30 @@ async function attemptCall(
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
allowCorrection: boolean
|
||||
outputFormat: OutputFormat,
|
||||
allowCorrection: boolean,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||
try {
|
||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs);
|
||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs, disableOAuth }), timeoutMs);
|
||||
if (allowCorrection && isErrorCallResult(result)) {
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, result, disableOAuth);
|
||||
if (resolution) {
|
||||
const retry = await maybeRetryResolvedTool(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
args,
|
||||
timeoutMs,
|
||||
outputFormat,
|
||||
resolution,
|
||||
disableOAuth
|
||||
);
|
||||
if (retry) {
|
||||
return retry;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result, resolvedTool: tool };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Timeout') {
|
||||
@ -426,36 +539,64 @@ async function attemptCall(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, error);
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, error, disableOAuth);
|
||||
if (!resolution) {
|
||||
maybeReportConnectionIssue(server, tool, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
entity: 'tool',
|
||||
attempted: tool,
|
||||
const retry = await maybeRetryResolvedTool(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
args,
|
||||
timeoutMs,
|
||||
outputFormat,
|
||||
resolution,
|
||||
scope: server,
|
||||
});
|
||||
if (resolution.kind === 'suggest') {
|
||||
if (messages.suggest) {
|
||||
console.error(dimText(messages.suggest));
|
||||
}
|
||||
disableOAuth
|
||||
);
|
||||
if (!retry) {
|
||||
throw error;
|
||||
}
|
||||
if (messages.auto) {
|
||||
console.log(dimText(messages.auto));
|
||||
}
|
||||
return attemptCall(runtime, server, resolution.value, args, timeoutMs, false);
|
||||
return retry;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRetryResolvedTool(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat,
|
||||
resolution: ToolResolution,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
entity: 'tool',
|
||||
attempted: tool,
|
||||
resolution,
|
||||
scope: server,
|
||||
});
|
||||
if (resolution.kind === 'suggest') {
|
||||
if (messages.suggest) {
|
||||
console.error(dimText(messages.suggest));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (messages.auto) {
|
||||
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
|
||||
emitAutoMessage(dimText(messages.auto));
|
||||
}
|
||||
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false, disableOAuth);
|
||||
}
|
||||
|
||||
async function maybeResolveToolName(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
attemptedTool: string,
|
||||
error: unknown
|
||||
error: unknown,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<ToolResolution | undefined> {
|
||||
const missingName = extractMissingToolFromError(error);
|
||||
if (!missingName) {
|
||||
@ -467,7 +608,7 @@ async function maybeResolveToolName(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false }).catch(() => undefined);
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth }).catch(() => undefined);
|
||||
if (!tools) {
|
||||
return undefined;
|
||||
}
|
||||
@ -483,14 +624,39 @@ async function maybeResolveToolName(
|
||||
}
|
||||
|
||||
function extractMissingToolFromError(error: unknown): string | undefined {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : undefined;
|
||||
const message = extractErrorMessageText(error);
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
const match = message.match(/Tool\s+([A-Za-z0-9._-]+)\s+not found/i);
|
||||
const match =
|
||||
message.match(/Tool\s+([A-Za-z0-9._-]+)\s+not found/i) ?? message.match(/Unknown tool:?\s+([A-Za-z0-9._-]+)/i);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function extractErrorMessageText(value: unknown): string | undefined {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const content = (value as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
return content
|
||||
.map((entry) =>
|
||||
entry && typeof entry === 'object' && typeof (entry as { text?: unknown }).text === 'string'
|
||||
? (entry as { text: string }).text
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function maybeReportConnectionIssue(server: string, tool: string, error: unknown): ConnectionIssue | undefined {
|
||||
const issue = analyzeConnectionError(error);
|
||||
const detail = summarizeIssueMessage(issue.rawMessage);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const CALL_HELP_ARGUMENT_LINES = [
|
||||
' key=value / key:value Flag-style named arguments.',
|
||||
' key=@path Read a UTF-8 string value from a file; use @@ for a literal @.',
|
||||
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
|
||||
' --args <json> Provide a JSON object payload.',
|
||||
' positional values Accepted when schema order is known.',
|
||||
@ -10,6 +11,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
|
||||
' --timeout <ms> Override the call timeout.',
|
||||
' --output text|markdown|json|raw Control formatting.',
|
||||
' --save-images <dir> Save image content blocks to a directory.',
|
||||
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||
' --raw-strings Keep numeric-looking argument values as strings.',
|
||||
' --no-coerce Keep all key/value and positional arguments as raw strings.',
|
||||
' --tail-log Stream returned log handles.',
|
||||
@ -18,6 +20,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
|
||||
export const CALL_HELP_ADHOC_SERVER_LINES = [
|
||||
' --http-url <url> Register an HTTP server for this run.',
|
||||
' --allow-http Permit plain http:// URLs with --http-url.',
|
||||
' --header KEY=value Attach HTTP headers (repeatable).',
|
||||
' --stdio <command> Run a stdio MCP server (repeat --stdio-arg for args).',
|
||||
' --stdio-arg <value> Append args to the stdio command (repeatable).',
|
||||
' --env KEY=value Inject env vars for stdio servers (repeatable).',
|
||||
@ -30,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
|
||||
|
||||
export const CALL_HELP_EXAMPLE_LINES = [
|
||||
' mcporter call linear.list_issues team=ENG limit:5',
|
||||
' mcporter call linear.create_comment body=@comment.md',
|
||||
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
|
||||
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
||||
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { resolveConfigPath } from '../config.js';
|
||||
import { resolveConfigPath } from '../config/path-discovery.js';
|
||||
import { parseLogLevel } from '../logging.js';
|
||||
import { extractFlags } from './flag-utils.js';
|
||||
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
|
||||
import { parsePositiveInteger } from './timeouts.js';
|
||||
|
||||
export interface GlobalCliContext {
|
||||
readonly globalFlags: Record<string, string | undefined>;
|
||||
@ -29,8 +30,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
|
||||
|
||||
let oauthTimeoutOverride: number | undefined;
|
||||
if (globalFlags['--oauth-timeout']) {
|
||||
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
|
||||
if (parsed === undefined) {
|
||||
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
|
||||
return { exit: true, code: 1 };
|
||||
}
|
||||
|
||||
@ -84,7 +84,14 @@ function isCallLikeToken(token: string): boolean {
|
||||
}
|
||||
|
||||
function isExplicitCommand(token: string): boolean {
|
||||
return token === 'list' || token === 'call' || token === 'auth';
|
||||
return (
|
||||
token === 'list' ||
|
||||
token === 'call' ||
|
||||
token === 'auth' ||
|
||||
token === 'vault' ||
|
||||
token === 'resource' ||
|
||||
token === 'resources'
|
||||
);
|
||||
}
|
||||
|
||||
function isUrlToken(token: string): boolean {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LoadConfigOptions, RawEntry } from '../../config.js';
|
||||
import { writeRawConfig } from '../../config.js';
|
||||
import { writeRawConfig, type LoadConfigOptions, type RawEntry } from '../../config.js';
|
||||
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
||||
import { expandHome } from '../../env.js';
|
||||
import { withFileLock } from '../../fs-json.js';
|
||||
import { mcporterDir } from '../../paths.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
@ -19,6 +19,9 @@ export type AddFlags = {
|
||||
headers: Record<string, string>;
|
||||
tokenCacheDir?: string;
|
||||
clientName?: string;
|
||||
oauthClientId?: string;
|
||||
oauthClientSecretEnv?: string;
|
||||
oauthTokenEndpointAuthMethod?: string;
|
||||
oauthRedirectUrl?: string;
|
||||
auth?: string;
|
||||
copyFrom?: string;
|
||||
@ -41,9 +44,6 @@ export async function handleAddCommand(options: ConfigCliOptions, args: string[]
|
||||
const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd());
|
||||
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 entry: RawEntry = baseEntry ? { ...baseEntry } : {};
|
||||
|
||||
@ -69,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.');
|
||||
}
|
||||
|
||||
if (!nextConfig.mcpServers) {
|
||||
nextConfig.mcpServers = {};
|
||||
}
|
||||
nextConfig.mcpServers[name] = entry;
|
||||
|
||||
if (flags.dryRun) {
|
||||
console.log(JSON.stringify({ [name]: entry }, null, 2));
|
||||
console.log('(dry-run) No changes were written.');
|
||||
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}`);
|
||||
}
|
||||
|
||||
@ -89,7 +94,7 @@ export function resolveWriteTarget(flags: AddFlags, loadOptions: LoadConfigOptio
|
||||
return path.resolve(expandHome(flags.persistPath));
|
||||
}
|
||||
if (flags.scope === 'home') {
|
||||
return path.join(os.homedir(), '.mcporter', 'mcporter.json');
|
||||
return path.join(mcporterDir('config'), 'mcporter.json');
|
||||
}
|
||||
if (flags.scope === 'project') {
|
||||
return path.resolve(rootDir, 'config', 'mcporter.json');
|
||||
@ -147,6 +152,18 @@ function extractAddFlags(args: string[]): AddFlags {
|
||||
flags.clientName = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--oauth-client-id':
|
||||
flags.oauthClientId = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--oauth-client-secret-env':
|
||||
flags.oauthClientSecretEnv = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--oauth-token-endpoint-auth-method':
|
||||
flags.oauthTokenEndpointAuthMethod = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--oauth-redirect-url':
|
||||
flags.oauthRedirectUrl = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
@ -284,6 +301,15 @@ function applyFlagsToEntry(entry: RawEntry, flags: AddFlags): void {
|
||||
if (flags.clientName) {
|
||||
entry.clientName = flags.clientName;
|
||||
}
|
||||
if (flags.oauthClientId) {
|
||||
entry.oauthClientId = flags.oauthClientId;
|
||||
}
|
||||
if (flags.oauthClientSecretEnv) {
|
||||
entry.oauthClientSecretEnv = flags.oauthClientSecretEnv;
|
||||
}
|
||||
if (flags.oauthTokenEndpointAuthMethod) {
|
||||
entry.oauthTokenEndpointAuthMethod = flags.oauthTokenEndpointAuthMethod;
|
||||
}
|
||||
if (flags.oauthRedirectUrl) {
|
||||
entry.oauthRedirectUrl = flags.oauthRedirectUrl;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { loadServerDefinitions } from '../../config.js';
|
||||
import { MCPORTER_VERSION } from '../../runtime.js';
|
||||
import { MCPORTER_VERSION } from '../../version.js';
|
||||
import { logConfigLocations, resolveConfigLocations } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
|
||||
@ -51,6 +51,12 @@ export const CONFIG_HELP_ENTRIES: Record<ConfigSubcommand, ConfigHelpEntry> = {
|
||||
{ flag: '--header KEY=value', description: 'Attach HTTP headers (repeatable).' },
|
||||
{ flag: '--token-cache-dir <path>', description: 'Override where OAuth tokens are persisted.' },
|
||||
{ flag: '--client-name <name>', description: 'Customize the OAuth client identifier.' },
|
||||
{ flag: '--oauth-client-id <id>', description: 'Use a pre-registered OAuth client id.' },
|
||||
{ flag: '--oauth-client-secret-env <env>', description: 'Read the OAuth client secret from an env var.' },
|
||||
{
|
||||
flag: '--oauth-token-endpoint-auth-method <method>',
|
||||
description: 'Set token auth, e.g. client_secret_post.',
|
||||
},
|
||||
{ flag: '--oauth-redirect-url <url>', description: 'Set a custom OAuth redirect URL.' },
|
||||
{ flag: '--auth <strategy>', description: 'Force the auth type (e.g., oauth).' },
|
||||
{ flag: '--copy-from <import:name>', description: 'Start with an imported definition by name.' },
|
||||
@ -92,8 +98,21 @@ export const CONFIG_HELP_ENTRIES: Record<ConfigSubcommand, ConfigHelpEntry> = {
|
||||
name: 'login <name|url> [options]',
|
||||
summary: 'Run the OAuth/auth flow',
|
||||
usage: 'mcporter config login <name|url> [options]',
|
||||
description: 'Delegates to `mcporter auth`, so you can pass ephemeral flags like --http-url/--stdio/--reset.',
|
||||
examples: ['pnpm mcporter config login linear', 'pnpm mcporter config login https://example.com/mcp --reset'],
|
||||
description:
|
||||
'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: {
|
||||
name: 'logout <name>',
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import type { RawEntry } from '../../config.js';
|
||||
import { writeRawConfig } from '../../config.js';
|
||||
import { resolveConfigPath, writeRawConfig, type RawEntry } from '../../config.js';
|
||||
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
||||
import { expandHome } from '../../env.js';
|
||||
import { withFileLock } from '../../fs-json.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
@ -53,19 +53,31 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
|
||||
}
|
||||
}
|
||||
if (flags.copy) {
|
||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
||||
const nextConfig = cloneConfig(config);
|
||||
if (!nextConfig.mcpServers) {
|
||||
nextConfig.mcpServers = {};
|
||||
}
|
||||
for (const item of entries) {
|
||||
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
|
||||
}
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
const lockPath = resolveImportCopyTarget(options.loadOptions.configPath, rootDir);
|
||||
let configPath = lockPath;
|
||||
await withFileLock(lockPath, async () => {
|
||||
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
|
||||
configPath = loaded.path;
|
||||
const nextConfig = cloneConfig(loaded.config);
|
||||
if (!nextConfig.mcpServers) {
|
||||
nextConfig.mcpServers = {};
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportCopyTarget(configPath: string | undefined, rootDir: string): string {
|
||||
if (configPath || process.env.MCPORTER_CONFIG) {
|
||||
return resolveConfigPath(configPath, rootDir).path;
|
||||
}
|
||||
return path.resolve(rootDir, 'config', 'mcporter.json');
|
||||
}
|
||||
|
||||
function extractImportFlags(args: string[]): ImportFlags {
|
||||
const flags: ImportFlags = { format: 'text' };
|
||||
let index = 0;
|
||||
|
||||
@ -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 { cloneConfig, findServerNameWithFuzzyMatch, loadOrCreateConfig } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
@ -8,13 +9,21 @@ export async function handleRemoveCommand(options: ConfigCliOptions, args: strin
|
||||
if (!name) {
|
||||
throw new CliUsageError('Usage: mcporter config remove <name>');
|
||||
}
|
||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
||||
const targetName = findServerNameWithFuzzyMatch(name, Object.keys(config.mcpServers ?? {}));
|
||||
if (!targetName) {
|
||||
throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`);
|
||||
}
|
||||
const nextConfig = cloneConfig(config);
|
||||
delete nextConfig.mcpServers[targetName];
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
const rootDir = options.loadOptions.rootDir ?? process.cwd();
|
||||
const lockPath = resolveConfigPath(options.loadOptions.configPath, rootDir).path;
|
||||
let configPath = lockPath;
|
||||
let targetName = name;
|
||||
await withFileLock(lockPath, async () => {
|
||||
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
|
||||
configPath = loaded.path;
|
||||
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}`);
|
||||
}
|
||||
|
||||
@ -9,8 +9,15 @@ export type SerializedServerDefinition = {
|
||||
auth?: ServerDefinition['auth'];
|
||||
tokenCacheDir?: string;
|
||||
clientName?: string;
|
||||
oauthClientId?: string;
|
||||
oauthClientSecretEnv?: string;
|
||||
oauthTokenEndpointAuthMethod?: string;
|
||||
oauthRedirectUrl?: string;
|
||||
oauthScope?: string;
|
||||
refresh?: ServerDefinition['refresh'];
|
||||
httpFetch?: ServerDefinition['httpFetch'];
|
||||
allowedTools?: readonly string[];
|
||||
blockedTools?: readonly string[];
|
||||
env?: Record<string, string>;
|
||||
transport: 'http' | 'stdio';
|
||||
baseUrl?: string;
|
||||
@ -30,8 +37,15 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
||||
auth: definition.auth,
|
||||
tokenCacheDir: definition.tokenCacheDir,
|
||||
clientName: definition.clientName,
|
||||
oauthClientId: definition.oauthClientId,
|
||||
oauthClientSecretEnv: definition.oauthClientSecretEnv,
|
||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||
oauthScope: definition.oauthScope,
|
||||
refresh: definition.refresh,
|
||||
httpFetch: definition.httpFetch,
|
||||
allowedTools: definition.allowedTools,
|
||||
blockedTools: definition.blockedTools,
|
||||
env: definition.env,
|
||||
transport: 'http',
|
||||
baseUrl: definition.command.url.href,
|
||||
@ -45,8 +59,15 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
|
||||
auth: definition.auth,
|
||||
tokenCacheDir: definition.tokenCacheDir,
|
||||
clientName: definition.clientName,
|
||||
oauthClientId: definition.oauthClientId,
|
||||
oauthClientSecretEnv: definition.oauthClientSecretEnv,
|
||||
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
|
||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||
oauthScope: definition.oauthScope,
|
||||
refresh: definition.refresh,
|
||||
httpFetch: definition.httpFetch,
|
||||
allowedTools: definition.allowedTools,
|
||||
blockedTools: definition.blockedTools,
|
||||
env: definition.env,
|
||||
transport: 'stdio',
|
||||
command: definition.command.command,
|
||||
@ -76,8 +97,16 @@ export function printServerSummary(definition: ServerDefinition): void {
|
||||
if (definition.description) {
|
||||
console.log(` ${label('Description')}: ${definition.description}`);
|
||||
}
|
||||
if (definition.auth === 'oauth') {
|
||||
console.log(` ${label('Auth')}: oauth`);
|
||||
if (definition.auth) {
|
||||
console.log(` ${label('Auth')}: ${definition.auth}`);
|
||||
}
|
||||
if (definition.allowedTools !== undefined) {
|
||||
const rendered = definition.allowedTools.length > 0 ? definition.allowedTools.join(', ') : '<none>';
|
||||
console.log(` ${label('Allowed tools')}: ${rendered}`);
|
||||
}
|
||||
if (definition.blockedTools !== undefined) {
|
||||
const rendered = definition.blockedTools.length > 0 ? definition.blockedTools.join(', ') : '<none>';
|
||||
console.log(` ${label('Blocked tools')}: ${rendered}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LoadConfigOptions, RawConfig } from '../../config.js';
|
||||
import { loadRawConfig, resolveConfigPath } from '../../config.js';
|
||||
import type { ServerDefinition } from '../../config-schema.js';
|
||||
import { mcporterConfigCandidates } from '../../paths.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from '../identifier-helpers.js';
|
||||
import { dimText, supportsAnsiColor } from '../terminal.js';
|
||||
@ -127,9 +127,7 @@ export function resolveServerDefinition(
|
||||
}
|
||||
|
||||
function buildSystemConfigCandidates(): string[] {
|
||||
const homeDir = os.homedir();
|
||||
const base = path.join(homeDir, '.mcporter');
|
||||
return [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')];
|
||||
return mcporterConfigCandidates();
|
||||
}
|
||||
|
||||
async function resolveFirstExisting(pathsToCheck: string[]): Promise<{ path: string; exists: boolean }> {
|
||||
|
||||
@ -68,7 +68,8 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--foreground Run the daemon in the current process (debug only).
|
||||
--log Enable daemon logging (defaults to ~/.mcporter/daemon/daemon-<hash>.log).
|
||||
--log Enable daemon logging (defaults to ~/.mcporter/daemon/daemon-<hash>.log,
|
||||
or $XDG_STATE_HOME/mcporter/daemon/... when set).
|
||||
--log-file <path> Write daemon stdout/stderr to a specific log file.
|
||||
--log-servers <csv> Only log call activity for the listed servers (implies --log).`);
|
||||
}
|
||||
|
||||
@ -93,6 +93,21 @@ export async function handleEmitTs(runtime: Runtime, args: string[]): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export function printEmitTsHelp(): void {
|
||||
console.error(
|
||||
[
|
||||
'Usage: mcporter emit-ts <server> --out <file> [flags]',
|
||||
'',
|
||||
'Flags:',
|
||||
' --mode types|client Emit declarations only or client + declarations.',
|
||||
' --out <path> Output .ts/.d.ts file.',
|
||||
' --types-out <path> Declaration output path for --mode client.',
|
||||
' --include-optional Include optional schema fields in signatures.',
|
||||
' --json Print a JSON summary.',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
|
||||
const flags: EmitTsFlags = {
|
||||
mode: 'types',
|
||||
@ -244,7 +259,7 @@ async function writeFile(targetPath: string, contents: string): Promise<void> {
|
||||
function computeImportPath(fromPath: string, typesPath: string): string {
|
||||
const fromDir = path.dirname(fromPath);
|
||||
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
|
||||
const withoutExt = relative.replace(/\.[^.]+$/, '');
|
||||
const withoutExt = relative.endsWith('.d.ts') ? relative.slice(0, -5) : relative.replace(/\.[^.]+$/, '');
|
||||
if (withoutExt.startsWith('.')) {
|
||||
return withoutExt;
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ export function renderTypesModule(input: EmitTypesTemplateInput): string {
|
||||
lines.push(`export interface ${input.interfaceName} {`);
|
||||
input.docs.forEach((entry, index) => {
|
||||
lines.push(...renderDocComment(entry.doc.docLines, ' '));
|
||||
const methodSignature = toInterfaceSignature(entry.doc.tsSignature, { wrapInPromise: true });
|
||||
const methodSignature = toInterfaceSignature(entry.doc.tsSignature, entry.toolName, { wrapInPromise: true });
|
||||
lines.push(` ${methodSignature}`);
|
||||
if (entry.doc.optionalSummary) {
|
||||
lines.push(` // ${entry.doc.optionalSummary.replace(/^\/\//, '').trim()}`);
|
||||
@ -76,10 +76,11 @@ export function renderClientModule(input: EmitClientTemplateInput): string {
|
||||
lines.push(` const proxy = createServerProxy(runtime, ${JSON.stringify(serverName)});`);
|
||||
lines.push(` const client: ${clientType} = {`);
|
||||
input.docs.forEach((entry, _index) => {
|
||||
const methodName = entry.doc.tsSignature.match(/^function\s+([^()]+)/)?.[1] ?? entry.toolName;
|
||||
lines.push(` async ${methodName}(params: Parameters<${input.interfaceName}['${methodName}']>[0]) {`);
|
||||
const memberName = toMemberName(entry.toolName);
|
||||
const indexKey = toIndexKey(entry.toolName);
|
||||
lines.push(` async ${memberName}(params: Parameters<${input.interfaceName}[${indexKey}]>[0]) {`);
|
||||
lines.push(
|
||||
` const tool = proxy.${entry.methodName} as (args: Parameters<${input.interfaceName}['${methodName}']>[0]) => Promise<unknown>;`
|
||||
` const tool = proxy.${entry.methodName} as (args: Parameters<${input.interfaceName}[${indexKey}]>[0]) => Promise<unknown>;`
|
||||
);
|
||||
lines.push(' const raw = await tool(params);');
|
||||
lines.push(' return wrapCallResult(raw).callResult;');
|
||||
@ -126,16 +127,26 @@ function renderDocComment(docLines: string[] | undefined, indent: string): strin
|
||||
return docLines.map((line) => `${indent}${line}`);
|
||||
}
|
||||
|
||||
function toInterfaceSignature(signature: string, options?: { wrapInPromise?: boolean }): string {
|
||||
function toInterfaceSignature(signature: string, toolName: string, options?: { wrapInPromise?: boolean }): string {
|
||||
const trimmed = signature.trim();
|
||||
const match = trimmed.match(/^function\s+([^(]+)\((.*)\)\s*(?::\s*([^;]+))?;?$/);
|
||||
if (!match) {
|
||||
return trimmed.replace(/^function\s+/, '');
|
||||
}
|
||||
const [, name, params, returnTypeRaw] = match;
|
||||
const [, , params, returnTypeRaw] = match;
|
||||
const returnType = (returnTypeRaw ?? 'void').trim();
|
||||
const finalReturn = options?.wrapInPromise ? `Promise<${returnType}>` : returnType;
|
||||
return `${name}(${params}): ${finalReturn};`;
|
||||
return `${toMemberName(toolName)}(${params}): ${finalReturn};`;
|
||||
}
|
||||
|
||||
const SAFE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
function toMemberName(name: string): string {
|
||||
return SAFE_IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
||||
}
|
||||
|
||||
function toIndexKey(name: string): string {
|
||||
return JSON.stringify(name);
|
||||
}
|
||||
|
||||
function describeTransport(definition: ServerDefinition): string | undefined {
|
||||
|
||||
@ -66,21 +66,24 @@ export function extractEphemeralServerFlags(
|
||||
|
||||
if (token === '--env') {
|
||||
const value = args[index + 1];
|
||||
if (!value?.includes('=')) {
|
||||
throw new Error("Flag '--env' requires KEY=value.");
|
||||
}
|
||||
const [key, ...rest] = value.split('=');
|
||||
if (!key) {
|
||||
throw new Error("Flag '--env' requires KEY=value.");
|
||||
}
|
||||
const current = ensureSpec();
|
||||
const envMap = current.env ? { ...current.env } : {};
|
||||
envMap[key] = rest.join('=');
|
||||
parseKeyValue(value, envMap, '--env');
|
||||
current.env = envMap;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--header') {
|
||||
const value = args[index + 1];
|
||||
const current = ensureSpec();
|
||||
const headerMap = current.headers ? { ...current.headers } : {};
|
||||
parseKeyValue(value, headerMap, '--header');
|
||||
current.headers = headerMap;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--cwd') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
@ -126,3 +129,14 @@ export function extractEphemeralServerFlags(
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
function parseKeyValue(value: string | undefined, target: Record<string, string>, flagName: string): void {
|
||||
if (!value?.includes('=')) {
|
||||
throw new Error(`Flag '${flagName}' requires KEY=value.`);
|
||||
}
|
||||
const [key, ...rest] = value.split('=');
|
||||
if (!key) {
|
||||
throw new Error(`Flag '${flagName}' requires KEY=value.`);
|
||||
}
|
||||
target[key] = rest.join('=');
|
||||
}
|
||||
|
||||
@ -17,9 +17,10 @@ interface PrepareEphemeralServerTargetOptions {
|
||||
reuseFromSpec?: boolean;
|
||||
}
|
||||
|
||||
interface PrepareEphemeralServerTargetResult {
|
||||
export interface PrepareEphemeralServerTargetResult {
|
||||
target?: string;
|
||||
resolution?: EphemeralServerResolution;
|
||||
persistPath?: string;
|
||||
}
|
||||
|
||||
export async function prepareEphemeralServerTarget(
|
||||
@ -85,7 +86,33 @@ export async function prepareEphemeralServerTarget(
|
||||
await persistEphemeralServer(resolution, spec.persistPath);
|
||||
}
|
||||
const resolvedTarget = target ?? resolution.name;
|
||||
return { target: resolvedTarget, resolution };
|
||||
return { target: resolvedTarget, resolution, persistPath: spec.persistPath };
|
||||
}
|
||||
|
||||
export async function persistPreparedEphemeralServer(
|
||||
runtime: Runtime,
|
||||
prepared: PrepareEphemeralServerTargetResult | undefined
|
||||
): Promise<void> {
|
||||
if (!prepared?.resolution || !prepared.persistPath) {
|
||||
return;
|
||||
}
|
||||
let currentDefinition;
|
||||
try {
|
||||
currentDefinition = runtime.getDefinition(prepared.resolution.name);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const persistedEntry = { ...prepared.resolution.persistedEntry };
|
||||
if (currentDefinition.auth === 'oauth') {
|
||||
persistedEntry.auth = 'oauth';
|
||||
}
|
||||
await persistEphemeralServer(
|
||||
{
|
||||
...prepared.resolution,
|
||||
persistedEntry,
|
||||
},
|
||||
prepared.persistPath
|
||||
);
|
||||
}
|
||||
|
||||
function applyNameHints(spec: EphemeralServerSpec | undefined, hints: string[] | undefined): void {
|
||||
|
||||
@ -6,6 +6,9 @@ export function extractFlags(args: string[], keys: readonly string[]): FlagMap {
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (token === '--') {
|
||||
break;
|
||||
}
|
||||
if (token === undefined || !keys.includes(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
|
||||
@ -80,3 +80,30 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P
|
||||
excludeTools: parsed.excludeTools,
|
||||
});
|
||||
}
|
||||
|
||||
export function printGenerateCliHelp(): void {
|
||||
console.error(
|
||||
[
|
||||
'Usage: mcporter generate-cli [server | command | url] [flags]',
|
||||
'',
|
||||
'Targets:',
|
||||
' <server> Use a configured server.',
|
||||
' <command|url> Infer an inline stdio or HTTP server.',
|
||||
' --server <name|json> Server name, HTTP URL, or JSON definition.',
|
||||
' --command <value> Inline stdio command or HTTP URL.',
|
||||
' --from <artifact> Regenerate from an existing generated CLI.',
|
||||
'',
|
||||
'Flags:',
|
||||
' --output <path> Write the TypeScript template to a path.',
|
||||
' --bundle [path] Emit a bundled JavaScript artifact.',
|
||||
' --compile [path] Emit a Bun-compiled binary.',
|
||||
' --runtime node|bun Runtime for generated code.',
|
||||
' --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.',
|
||||
' --exclude-tools a,b Omit these tools.',
|
||||
' --dry-run Print regeneration command for --from.',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import fsSync from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { builtinModules, createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { RolldownPlugin } from 'rolldown';
|
||||
import { MCPORTER_VERSION } from '../../version.js';
|
||||
import { markExecutable, safeCopyFile } from './fs-helpers.js';
|
||||
import { verifyBunAvailable } from './runtime.js';
|
||||
|
||||
@ -15,6 +16,7 @@ const packageRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
||||
// even in empty temp dirs (fixes #1).
|
||||
const BUNDLED_DEPENDENCIES = ['commander', 'mcporter', 'jsonc-parser'] as const;
|
||||
const dependencyAliasPlugin = createLocalDependencyAliasPlugin([...BUNDLED_DEPENDENCIES]);
|
||||
const NODE_BUILTIN_SPECIFIERS = new Set(builtinModules.flatMap((specifier) => [specifier, `node:${specifier}`]));
|
||||
|
||||
export async function bundleOutput({
|
||||
sourcePath,
|
||||
@ -69,19 +71,52 @@ async function bundleWithRolldown({
|
||||
if (typeof (log as { code?: string }).code === 'string' && (log as { code?: string }).code === 'EVAL') {
|
||||
return;
|
||||
}
|
||||
if (isExpectedNodeBuiltinWarning(log)) {
|
||||
return;
|
||||
}
|
||||
handler(level, log);
|
||||
},
|
||||
});
|
||||
const format = outputFormatForTarget(absTarget, runtimeKind);
|
||||
await bundle.write({
|
||||
file: absTarget,
|
||||
format: runtimeKind === 'bun' ? 'esm' : 'cjs',
|
||||
format,
|
||||
codeSplitting: false,
|
||||
sourcemap: false,
|
||||
minify,
|
||||
...(format === 'esm' ? { banner: buildEsmRequireBanner() } : {}),
|
||||
});
|
||||
await markExecutable(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({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
@ -110,7 +145,7 @@ async function bundleWithBun({
|
||||
args.push('--minify');
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(bunBin, args, { cwd: packageRoot, env: process.env }, (error) => {
|
||||
execFile(bunBin, args, { cwd: stagingDir, env: process.env }, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
@ -255,6 +290,62 @@ async function ensureBundlerDeps(stagingDir: string): Promise<void> {
|
||||
await linkOrCopyDependency(sourceDir, target);
|
||||
})
|
||||
);
|
||||
const missing = await findMissingBundlerDeps(stagingDir);
|
||||
if (missing.length > 0) {
|
||||
await installPublishedBundlerDeps(stagingDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function findMissingBundlerDeps(stagingDir: string): Promise<string[]> {
|
||||
const missing: string[] = [];
|
||||
for (const specifier of BUNDLED_DEPENDENCIES) {
|
||||
const pkgPath = path.join(stagingDir, 'node_modules', specifier, 'package.json');
|
||||
try {
|
||||
await fs.access(pkgPath);
|
||||
} catch {
|
||||
missing.push(specifier);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function installPublishedBundlerDeps(stagingDir: string): Promise<void> {
|
||||
const installSpec = process.env.MCPORTER_BUNDLER_DEP_PACKAGE ?? MCPORTER_VERSION;
|
||||
if (installSpec === '0.0.0-dev') {
|
||||
throw new Error(
|
||||
'Unable to resolve generated-CLI bundler dependencies from this standalone mcporter binary. Install mcporter via npm/Homebrew or publish the matching mcporter package before using --compile.'
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(stagingDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies: { mcporter: installSpec } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
'npm',
|
||||
['install', '--ignore-scripts', '--no-audit', '--no-fund', '--min-release-age=0'],
|
||||
{ cwd: stagingDir, env: process.env },
|
||||
(error) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to install ${formatMcporterInstallSpec(installSpec)} dependencies needed for Bun compilation from this standalone binary. Install mcporter via npm/Homebrew, or ensure npm can reach the registry.\n\n${error.message}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function formatMcporterInstallSpec(installSpec: string): string {
|
||||
if (installSpec === MCPORTER_VERSION) {
|
||||
return `mcporter@${MCPORTER_VERSION}`;
|
||||
}
|
||||
return `mcporter from ${installSpec}`;
|
||||
}
|
||||
|
||||
function resolveDependencyDirectory(specifier: (typeof BUNDLED_DEPENDENCIES)[number]): string | undefined {
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CliArtifactMetadata } from '../../cli-metadata.js';
|
||||
import { type HttpCommand, loadServerDefinitions, type ServerDefinition, type StdioCommand } from '../../config.js';
|
||||
import {
|
||||
type HttpCommand,
|
||||
loadServerDefinitions,
|
||||
type RawLifecycle,
|
||||
type RefreshableBearerOptions,
|
||||
type ServerDefinition,
|
||||
type ServerLoggingOptions,
|
||||
type StdioCommand,
|
||||
} from '../../config.js';
|
||||
import { resolveLifecycle } from '../../lifecycle.js';
|
||||
import type { Runtime, ServerToolInfo } from '../../runtime.js';
|
||||
import { createRuntime } from '../../runtime.js';
|
||||
import { extractHttpServerTarget, normalizeHttpUrl } from '../http-utils.js';
|
||||
@ -176,10 +185,6 @@ function pickDescription(
|
||||
}
|
||||
|
||||
export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
|
||||
if (isServerDefinition(def)) {
|
||||
return def;
|
||||
}
|
||||
|
||||
const name = def.name;
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
throw new Error('Server definition must include a name.');
|
||||
@ -190,29 +195,67 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
|
||||
const auth = typeof def.auth === 'string' ? def.auth : undefined;
|
||||
const tokenCacheDir = typeof def.tokenCacheDir === 'string' ? def.tokenCacheDir : undefined;
|
||||
const clientName = typeof def.clientName === 'string' ? def.clientName : undefined;
|
||||
const record = def as Record<string, unknown>;
|
||||
const oauthClientId = stringFromAliases(record, 'oauthClientId', 'oauth_client_id');
|
||||
const oauthClientSecret = stringFromAliases(record, 'oauthClientSecret', 'oauth_client_secret');
|
||||
const oauthClientSecretEnv = stringFromAliases(record, 'oauthClientSecretEnv', 'oauth_client_secret_env');
|
||||
const oauthTokenEndpointAuthMethod = stringFromAliases(
|
||||
record,
|
||||
'oauthTokenEndpointAuthMethod',
|
||||
'oauth_token_endpoint_auth_method'
|
||||
);
|
||||
const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : 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 oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command);
|
||||
const rawLifecycle = getRawLifecycle(record.lifecycle);
|
||||
const logging = getLogging(record.logging);
|
||||
const allowedTools = getOptionalStringArray(record.allowedTools ?? record.allowed_tools, 'allowedTools');
|
||||
const blockedTools = getOptionalStringArray(record.blockedTools ?? record.blocked_tools, 'blockedTools');
|
||||
if (allowedTools !== undefined && blockedTools !== undefined) {
|
||||
throw new Error(`Server definition '${name}' cannot specify both allowedTools and blockedTools.`);
|
||||
}
|
||||
const shared = (
|
||||
command: ServerDefinition['command']
|
||||
): Omit<ServerDefinition, 'name' | 'description' | 'command'> => ({
|
||||
env,
|
||||
auth,
|
||||
tokenCacheDir,
|
||||
clientName,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
oauthClientSecretEnv,
|
||||
oauthTokenEndpointAuthMethod,
|
||||
oauthRedirectUrl,
|
||||
oauthScope,
|
||||
oauthCommand,
|
||||
refresh,
|
||||
httpFetch,
|
||||
lifecycle: resolveLifecycle(name, rawLifecycle, command),
|
||||
logging,
|
||||
...(allowedTools !== undefined ? { allowedTools } : {}),
|
||||
...(blockedTools !== undefined ? { blockedTools } : {}),
|
||||
});
|
||||
|
||||
const commandValue = def.command;
|
||||
if (isCommandSpec(commandValue)) {
|
||||
const command = normalizeCommand(commandValue, headers);
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
command: normalizeCommand(commandValue, headers),
|
||||
env,
|
||||
auth,
|
||||
tokenCacheDir,
|
||||
clientName,
|
||||
command,
|
||||
...shared(command),
|
||||
};
|
||||
}
|
||||
if (typeof commandValue === 'string' && commandValue.trim().length > 0) {
|
||||
const command = toCommandSpec(commandValue, getStringArray(record.args), headers ? { headers } : undefined);
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
command: toCommandSpec(commandValue, getStringArray(def.args), headers ? { headers } : undefined),
|
||||
env,
|
||||
auth,
|
||||
tokenCacheDir,
|
||||
clientName,
|
||||
command,
|
||||
...shared(command),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(commandValue) && commandValue.length > 0) {
|
||||
@ -220,30 +263,17 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
|
||||
if (typeof first !== 'string' || !rest.every((entry) => typeof entry === 'string')) {
|
||||
throw new Error('Command array must contain only strings.');
|
||||
}
|
||||
const command = toCommandSpec(first, rest as string[], headers ? { headers } : undefined);
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
command: toCommandSpec(first, rest as string[], headers ? { headers } : undefined),
|
||||
env,
|
||||
auth,
|
||||
tokenCacheDir,
|
||||
clientName,
|
||||
command,
|
||||
...shared(command),
|
||||
};
|
||||
}
|
||||
throw new Error('Server definition must include command information.');
|
||||
}
|
||||
|
||||
function isServerDefinition(value: unknown): value is ServerDefinition {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return isCommandSpec(record.command);
|
||||
}
|
||||
|
||||
function isCommandSpec(value: unknown): value is ServerDefinition['command'] {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
@ -311,6 +341,88 @@ function getStringArray(value: unknown): string[] | undefined {
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function getOptionalStringArray(value: unknown, fieldName: string): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
|
||||
throw new Error(`${fieldName} must be an array of strings.`);
|
||||
}
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function getRawLifecycle(value: unknown): RawLifecycle | undefined {
|
||||
if (value === 'keep-alive' || value === 'ephemeral') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const record = value as { mode?: unknown; idleTimeoutMs?: unknown };
|
||||
if (record.mode === 'keep-alive' || record.mode === 'ephemeral') {
|
||||
return {
|
||||
mode: record.mode,
|
||||
...(typeof record.idleTimeoutMs === 'number' ? { idleTimeoutMs: record.idleTimeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getLogging(value: unknown): ServerLoggingOptions | undefined {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const daemon = (value as { daemon?: unknown }).daemon;
|
||||
if (typeof daemon !== 'object' || daemon === null) {
|
||||
return undefined;
|
||||
}
|
||||
const enabled = (daemon as { enabled?: unknown }).enabled;
|
||||
return typeof enabled === 'boolean' ? { daemon: { enabled } } : { daemon: {} };
|
||||
}
|
||||
|
||||
function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | undefined {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const args = getStringArray((value as { args?: unknown }).args);
|
||||
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 {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toStringRecord(value: unknown): Record<string, string> | undefined {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { parsePositiveInteger } from '../timeouts.js';
|
||||
|
||||
export interface GeneratorCommonFlags {
|
||||
runtime?: 'node' | 'bun';
|
||||
timeout?: number;
|
||||
@ -31,8 +33,8 @@ export function extractGeneratorFlags(args: string[], options: ExtractOptions =
|
||||
if (!raw) {
|
||||
throw new Error("Flag '--timeout' requires a value.");
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const parsed = parsePositiveInteger(raw);
|
||||
if (parsed === undefined) {
|
||||
throw new Error('--timeout must be a positive integer.');
|
||||
}
|
||||
result.timeout = parsed;
|
||||
|
||||
@ -17,7 +17,9 @@ export async function performGenerateFromArtifact(
|
||||
|
||||
export async function performGenerateFromRequest(request: GenerateCliOptions): Promise<void> {
|
||||
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) {
|
||||
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) {
|
||||
\t\tlines.push('', tint.extraDim(generatorInfo));
|
||||
\t}
|
||||
\treturn lines.join('\\n');
|
||||
\treturn lines.join('\\n') + '\\n';
|
||||
}
|
||||
|
||||
program.helpInformation = () => renderStandaloneHelp();
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CliArtifactMetadata } from '../../cli-metadata.js';
|
||||
import type { ServerDefinition } from '../../config.js';
|
||||
import { MCPORTER_VERSION } from '../../runtime.js';
|
||||
import { MCPORTER_VERSION } from '../../version.js';
|
||||
import { buildToolDoc, type ToolOptionDoc } from '../list-detail-helpers.js';
|
||||
import { markExecutable } from './fs-helpers.js';
|
||||
import { renderEmbeddedHelpSource } from './template-help.js';
|
||||
import type { GeneratedOption, ToolMetadata } from './tools.js';
|
||||
import { buildEmbeddedSchemaMap } from './tools.js';
|
||||
import { stableJsonStringify } from './stable-json.js';
|
||||
|
||||
export interface TemplateInput {
|
||||
outputPath?: string;
|
||||
runtimeScriptPath?: string;
|
||||
runtimeKind: 'node' | 'bun';
|
||||
timeoutMs: number;
|
||||
definition: ServerDefinition;
|
||||
@ -27,8 +30,13 @@ export async function writeTemplate(input: TemplateInput): Promise<string> {
|
||||
const resolvedOutput = input.outputPath
|
||||
? path.resolve(input.outputPath)
|
||||
: path.resolve(process.cwd(), `${input.serverName}.ts`);
|
||||
const runtimeScriptPath = input.runtimeScriptPath ? path.resolve(input.runtimeScriptPath) : resolvedOutput;
|
||||
await fs.mkdir(path.dirname(resolvedOutput), { recursive: true });
|
||||
await fs.writeFile(resolvedOutput, renderTemplate(input), 'utf8');
|
||||
await fs.writeFile(
|
||||
resolvedOutput,
|
||||
renderTemplate({ ...input, outputPath: resolvedOutput, runtimeScriptPath }),
|
||||
'utf8'
|
||||
);
|
||||
await markExecutable(resolvedOutput);
|
||||
return resolvedOutput;
|
||||
}
|
||||
@ -58,14 +66,22 @@ export function renderTemplate({
|
||||
tools,
|
||||
generator,
|
||||
metadata,
|
||||
outputPath,
|
||||
runtimeScriptPath,
|
||||
}: TemplateInput): string {
|
||||
const imports = [
|
||||
"import path from 'node:path';",
|
||||
"import { fileURLToPath } from 'node:url';",
|
||||
"import { Command } from 'commander';",
|
||||
"import { createRuntime, createServerProxy } from 'mcporter';",
|
||||
"import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';",
|
||||
"import { createCallResult } from 'mcporter';",
|
||||
].join('\n');
|
||||
const embedded = JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value), 2);
|
||||
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/steipete/mcporter`;
|
||||
const embedded = stableJsonStringify(
|
||||
JSON.parse(JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value))),
|
||||
2
|
||||
);
|
||||
const relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
|
||||
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/openclaw/mcporter`;
|
||||
const toolDocs = tools.map((tool) => ({
|
||||
tool,
|
||||
doc: buildToolDoc({
|
||||
@ -85,6 +101,7 @@ export function renderTemplate({
|
||||
tool: entry.tool,
|
||||
})
|
||||
);
|
||||
assertUniqueGeneratedCommandNames(renderedTools);
|
||||
const toolHelp = renderedTools.map((entry) => ({
|
||||
name: entry.commandName,
|
||||
description: entry.tool.tool.description ?? '',
|
||||
@ -92,19 +109,19 @@ export function renderTemplate({
|
||||
flags: entry.doc.flagUsage ?? '',
|
||||
}));
|
||||
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
|
||||
const toolHelpLiteral = JSON.stringify(toolHelp, undefined, 2);
|
||||
const embeddedSchemas = JSON.stringify(buildEmbeddedSchemaMap(tools), undefined, 2);
|
||||
const embeddedMetadata = JSON.stringify(metadata, undefined, 2);
|
||||
const toolHelpLiteral = stableJsonStringify(toolHelp, 2);
|
||||
const embeddedSchemas = stableJsonStringify(buildEmbeddedSchemaMap(tools), 2);
|
||||
const embeddedMetadata = stableJsonStringify(metadata, 2);
|
||||
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
|
||||
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
|
||||
const signatureMapLiteral = JSON.stringify(signatureMap, undefined, 2);
|
||||
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version} on ${
|
||||
metadata.generatedAt
|
||||
}. DO NOT EDIT.`;
|
||||
const signatureMapLiteral = stableJsonStringify(signatureMap, 2);
|
||||
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version}. DO NOT EDIT.`;
|
||||
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
|
||||
${generatedHeaderComment}
|
||||
${imports}
|
||||
|
||||
const __mcpScriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __mcpRelativeStdioCwd: string | null = ${JSON.stringify(relativeStdioCwd)};
|
||||
const embeddedServer = ${embedded} as const;
|
||||
const embeddedSchemas = ${embeddedSchemas} as const;
|
||||
const embeddedName = ${JSON.stringify(serverName)};
|
||||
@ -164,14 +181,16 @@ ${renderEmbeddedHelpSource()}
|
||||
|
||||
function printResult(result: unknown, format: string) {
|
||||
\tconst wrapped = createCallResult(result);
|
||||
\tconst rawPayload = unwrapRawPayload(wrapped.raw);
|
||||
\tswitch (format) {
|
||||
\t\tcase 'json': {
|
||||
\t\t\tconst json = wrapped.json();
|
||||
\t\t\tif (json) {
|
||||
\t\t\tif (json !== null) {
|
||||
\t\t\t\tconsole.log(JSON.stringify(json, null, 2));
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tbreak;
|
||||
\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
|
||||
\t\t\treturn;
|
||||
\t\t}
|
||||
\t\tcase 'markdown': {
|
||||
\t\t\tconst markdown = wrapped.markdown();
|
||||
@ -182,7 +201,7 @@ function printResult(result: unknown, format: string) {
|
||||
\t\t\tbreak;
|
||||
\t\t}
|
||||
\t\tcase 'raw': {
|
||||
\t\t\tconsole.log(JSON.stringify(wrapped.raw, null, 2));
|
||||
\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
|
||||
\t\t\treturn;
|
||||
\t\t}
|
||||
\t}
|
||||
@ -190,10 +209,52 @@ function printResult(result: unknown, format: string) {
|
||||
\tif (text) {
|
||||
\t\tconsole.log(text);
|
||||
\t} else {
|
||||
\t\tconsole.log(JSON.stringify(wrapped.raw, null, 2));
|
||||
\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
|
||||
\t}
|
||||
}
|
||||
|
||||
function unwrapRawPayload(value: unknown): unknown {
|
||||
\tif (value && typeof value === 'object' && 'raw' in value) {
|
||||
\t\treturn (value as { raw: unknown }).raw;
|
||||
\t}
|
||||
\treturn value;
|
||||
}
|
||||
|
||||
function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolean' | 'json') {
|
||||
\tconst trimmed = value.trim();
|
||||
\tif (trimmed.startsWith('[')) {
|
||||
\t\tconst parsed = JSON.parse(trimmed);
|
||||
\t\tif (!Array.isArray(parsed)) {
|
||||
\t\t\tthrow new Error('Expected a JSON array.');
|
||||
\t\t}
|
||||
\t\treturn parsed;
|
||||
\t}
|
||||
\tif (itemType === 'json') {
|
||||
\t\tconst parsed = JSON.parse('[' + value + ']');
|
||||
\t\tif (!Array.isArray(parsed)) {
|
||||
\t\t\tthrow new Error('Expected JSON array items.');
|
||||
\t\t}
|
||||
\t\treturn parsed;
|
||||
\t}
|
||||
\tconst values = value.split(',').map((entry) => entry.trim());
|
||||
\tif (itemType === 'number') {
|
||||
\t\treturn values.map((entry) => parseFiniteNumber(entry));
|
||||
\t}
|
||||
\tif (itemType === 'boolean') {
|
||||
\t\treturn values.map((entry) => entry !== 'false');
|
||||
\t}
|
||||
\treturn values;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value: string): number {
|
||||
\tconst trimmed = value.trim();
|
||||
\tconst parsed = Number(trimmed);
|
||||
\tif (trimmed === '' || !Number.isFinite(parsed)) {
|
||||
\t\tthrow new Error('Expected a finite number.');
|
||||
\t}
|
||||
\treturn parsed;
|
||||
}
|
||||
|
||||
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
||||
\tconst base = { ...server } as Record<string, unknown>;
|
||||
\tif ((server.command as any).kind === 'http') {
|
||||
@ -208,11 +269,19 @@ function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
||||
\t\t};
|
||||
\t}
|
||||
\tif ((server.command as any).kind === 'stdio') {
|
||||
\t\tconst stdio = server.command as Record<string, unknown>;
|
||||
\t\tconst resolvedCwd =
|
||||
\t\t\t__mcpRelativeStdioCwd !== null
|
||||
\t\t\t\t? path.resolve(__mcpScriptDir, __mcpRelativeStdioCwd)
|
||||
\t\t\t\t: typeof stdio.cwd === 'string' && stdio.cwd.length > 0
|
||||
\t\t\t\t\t? stdio.cwd
|
||||
\t\t\t\t\t: undefined;
|
||||
\t\treturn {
|
||||
\t\t\t...base,
|
||||
\t\t\tcommand: {
|
||||
\t\t\t\t...(server.command as Record<string, unknown>),
|
||||
\t\t\t\t...stdio,
|
||||
\t\t\t\targs: [ ...((server.command as any).args ?? []) ],
|
||||
\t\t\t\t...(resolvedCwd !== undefined ? { cwd: resolvedCwd } : {}),
|
||||
\t\t\t},
|
||||
\t\t};
|
||||
\t}
|
||||
@ -224,7 +293,7 @@ function determineArtifactKind(): 'template' | 'bundle' | 'binary' {
|
||||
\tif (scriptPath.endsWith('.ts')) {
|
||||
\t\treturn 'template';
|
||||
\t}
|
||||
\tif (scriptPath.endsWith('.js')) {
|
||||
\tif (scriptPath.endsWith('.js') || scriptPath.endsWith('.mjs') || scriptPath.endsWith('.cjs')) {
|
||||
\t\treturn 'bundle';
|
||||
\t}
|
||||
\treturn 'binary';
|
||||
@ -260,10 +329,12 @@ function buildMetadataPayload() {
|
||||
\t};
|
||||
}
|
||||
|
||||
async function ensureRuntime(): Promise<Awaited<ReturnType<typeof createRuntime>>> {
|
||||
return await createRuntime({
|
||||
servers: [normalizeEmbeddedServer(embeddedServer)],
|
||||
async function ensureRuntime() {
|
||||
const server = normalizeEmbeddedServer(embeddedServer);
|
||||
const baseRuntime = await createRuntime({
|
||||
servers: [server as any],
|
||||
});
|
||||
return await createGeneratedKeepAliveRuntime(baseRuntime, server as any);
|
||||
}
|
||||
|
||||
async function invokeWithTimeout<T>(call: Promise<T>, timeout: number): Promise<T> {
|
||||
@ -287,8 +358,54 @@ async function invokeWithTimeout<T>(call: Promise<T>, timeout: number): Promise<
|
||||
\t}
|
||||
}
|
||||
|
||||
function parseGeneratedDaemonInvocation(rawArgs: string[]): { args: string[]; configPath: string; rootDir?: string } | null {
|
||||
\tconst args = [...rawArgs];
|
||||
\tlet configPath: string | undefined;
|
||||
\tlet rootDir: string | undefined;
|
||||
\twhile (args.length > 0) {
|
||||
\t\tconst token = args[0];
|
||||
\t\tif (token === '--config') {
|
||||
\t\t\targs.shift();
|
||||
\t\t\tconfigPath = args.shift();
|
||||
\t\t\tcontinue;
|
||||
\t\t}
|
||||
\t\tif (token?.startsWith('--config=')) {
|
||||
\t\t\targs.shift();
|
||||
\t\t\tconfigPath = token.slice('--config='.length);
|
||||
\t\t\tcontinue;
|
||||
\t\t}
|
||||
\t\tif (token === '--root') {
|
||||
\t\t\targs.shift();
|
||||
\t\t\trootDir = args.shift();
|
||||
\t\t\tcontinue;
|
||||
\t\t}
|
||||
\t\tif (token?.startsWith('--root=')) {
|
||||
\t\t\targs.shift();
|
||||
\t\t\trootDir = token.slice('--root='.length);
|
||||
\t\t\tcontinue;
|
||||
\t\t}
|
||||
\t\tbreak;
|
||||
\t}
|
||||
\tif (args[0] !== 'daemon') {
|
||||
\t\treturn null;
|
||||
\t}
|
||||
\tif (!configPath) {
|
||||
\t\tthrow new Error('Generated daemon invocation is missing --config.');
|
||||
\t}
|
||||
\treturn { args: args.slice(1), configPath, rootDir };
|
||||
}
|
||||
|
||||
async function runCli(): Promise<void> {
|
||||
\tconst args = process.argv.slice(2);
|
||||
\tconst daemonInvocation = parseGeneratedDaemonInvocation(args);
|
||||
\tif (daemonInvocation) {
|
||||
\t\tawait handleDaemonCli([...daemonInvocation.args], {
|
||||
\t\t\tconfigPath: daemonInvocation.configPath,
|
||||
\t\t\tconfigExplicit: true,
|
||||
\t\t\trootDir: daemonInvocation.rootDir,
|
||||
\t\t});
|
||||
\t\treturn;
|
||||
\t}
|
||||
\tif (args.length === 0) {
|
||||
\t\tprogram.outputHelp();
|
||||
\t\treturn;
|
||||
@ -338,6 +455,30 @@ export function renderToolCommand(
|
||||
return `if (${source} !== undefined) args.${option.property} = ${source};`;
|
||||
})
|
||||
.join('\n\t\t');
|
||||
const requiredChecks = tool.options
|
||||
.filter((option) => option.required)
|
||||
.map((option) => {
|
||||
const camelCaseProp = option.cliName
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((segment, index) => (index === 0 ? segment : `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`))
|
||||
.join('');
|
||||
return { option, camelCaseProp };
|
||||
});
|
||||
const requiredValidation =
|
||||
requiredChecks.length > 0
|
||||
? `const missingRequired = [${requiredChecks
|
||||
.map(
|
||||
({ option, camelCaseProp }) =>
|
||||
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
|
||||
)
|
||||
.join(
|
||||
', '
|
||||
)}].filter((entry) => entry.value === undefined || (typeof entry.value === 'string' && entry.value.trim() === '')).map((entry) => entry.flag);
|
||||
\t\t\tif (missingRequired.length > 0) {
|
||||
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
||||
\t\t\t}`
|
||||
: '';
|
||||
const flagUsage = doc.flagUsage;
|
||||
const optionLines = doc.optionDocs.map((entry) => renderOption(entry)).join('\n');
|
||||
const summary = flagUsage ? `${commandName} ${flagUsage}` : commandName;
|
||||
@ -360,19 +501,23 @@ ${usageSnippet ? `\t${usageSnippet}` : ''}\t.option('--raw <json>', 'Provide raw
|
||||
${optionLines ? `\n${optionLines}` : ''}
|
||||
${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => {
|
||||
\t\tconst globalOptions = program.opts();
|
||||
\t\tconst runtime = await ensureRuntime();
|
||||
\t\tconst runtimeContext = await ensureRuntime();
|
||||
\t\tconst runtime = runtimeContext.runtime;
|
||||
\t\tconst serverName = embeddedName;
|
||||
\t\tconst proxy = createServerProxy(runtime, serverName, {
|
||||
\t\t\tinitialSchemas: embeddedSchemas,
|
||||
\t\t});
|
||||
\t\ttry {
|
||||
\t\t\tconst args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
||||
\t\t\t${buildArgs}
|
||||
\t\t\tif (!cmdOpts.raw) {
|
||||
\t\t\t\t${requiredValidation}
|
||||
\t\t\t\t${buildArgs}
|
||||
\t\t\t}
|
||||
\t\t\tconst call = (proxy.${tool.methodName} as any)(args);
|
||||
\t\t\tconst result = await invokeWithTimeout(call, globalOptions.timeout || ${defaultTimeout});
|
||||
\t\t\tprintResult(result, globalOptions.output ?? 'text');
|
||||
\t\t} finally {
|
||||
\t\t\tawait runtime.close(serverName).catch(() => {});
|
||||
\t\t\tawait runtimeContext.close(serverName).catch(() => {});
|
||||
\t\t}
|
||||
\t})${exampleSnippet}${optionalSnippet};`;
|
||||
return { block, commandName, signature, tsSignature };
|
||||
@ -380,16 +525,43 @@ ${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => {
|
||||
|
||||
function renderOption(optionDoc: ToolOptionDoc): string {
|
||||
const parser = optionParser(optionDoc.option);
|
||||
const method = optionDoc.option.required ? '.requiredOption' : '.option';
|
||||
return `\t${method}(${JSON.stringify(optionDoc.flagLabel)}, ${JSON.stringify(optionDoc.description)}${
|
||||
return `\t.option(${JSON.stringify(optionDoc.flagLabel)}, ${JSON.stringify(optionDoc.description)}${
|
||||
parser ? `, ${parser}` : ''
|
||||
})`;
|
||||
}
|
||||
|
||||
function computeRelativeStdioCwd(definition: ServerDefinition, outputPath?: string): string | null {
|
||||
if (!outputPath || definition.command.kind !== 'stdio') {
|
||||
return null;
|
||||
}
|
||||
const rawCwd = definition.command.cwd;
|
||||
if (typeof rawCwd === 'string' && path.isAbsolute(rawCwd)) {
|
||||
return null;
|
||||
}
|
||||
const baseCwd = typeof rawCwd === 'string' && rawCwd.length > 0 ? rawCwd : process.cwd();
|
||||
const absoluteCwd = realpathIfExists(path.resolve(process.cwd(), baseCwd));
|
||||
const outputDir = realpathIfExists(path.dirname(path.resolve(outputPath)));
|
||||
const relative = path.relative(outputDir, absoluteCwd);
|
||||
if (path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return relative === '' ? '.' : relative;
|
||||
}
|
||||
|
||||
function realpathIfExists(value: string): string {
|
||||
try {
|
||||
return realpathSync.native(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const templateTestHelpers = { computeRelativeStdioCwd };
|
||||
|
||||
function optionParser(option: GeneratedOption): string | undefined {
|
||||
switch (option.type) {
|
||||
case 'number':
|
||||
return '(value) => parseFloat(value)';
|
||||
return '(value) => parseFiniteNumber(value)';
|
||||
case 'boolean':
|
||||
return "(value) => value !== 'false'";
|
||||
case 'object':
|
||||
@ -398,13 +570,28 @@ function optionParser(option: GeneratedOption): string | undefined {
|
||||
// Coerce array elements to their proper types based on schema
|
||||
switch (option.arrayItemType) {
|
||||
case 'number':
|
||||
return "(value) => value.split(',').map((v) => parseFloat(v.trim()))";
|
||||
return "(value) => parseArrayOption(value, 'number')";
|
||||
case 'boolean':
|
||||
return "(value) => value.split(',').map((v) => v.trim() !== 'false')";
|
||||
return "(value) => parseArrayOption(value, 'boolean')";
|
||||
case 'object':
|
||||
return "(value) => parseArrayOption(value, 'json')";
|
||||
default:
|
||||
return "(value) => value.split(',').map((v) => v.trim())";
|
||||
return "(value) => parseArrayOption(value, 'string')";
|
||||
}
|
||||
default:
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export interface GeneratedOption {
|
||||
description?: string;
|
||||
required: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown';
|
||||
arrayItemType?: 'string' | 'number' | 'boolean' | 'unknown';
|
||||
arrayItemType?: 'string' | 'number' | 'boolean' | 'object' | 'unknown';
|
||||
placeholder: string;
|
||||
exampleValue?: string;
|
||||
enumValues?: string[];
|
||||
@ -34,7 +34,7 @@ function resolveArrayItemType(value: unknown): GeneratedOption['arrayItemType']
|
||||
if (value === 'integer') {
|
||||
return 'number';
|
||||
}
|
||||
if (value === 'string' || value === 'number' || value === 'boolean') {
|
||||
if (value === 'string' || value === 'number' || value === 'boolean' || value === 'object') {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
@ -50,9 +50,30 @@ export function buildToolMetadata(tool: ServerToolInfo): ToolMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolMetadataList(
|
||||
tools: ServerToolInfo[],
|
||||
options: { readonly sort?: boolean } = {}
|
||||
): ToolMetadata[] {
|
||||
const result = tools.map((tool) => buildToolMetadata(tool));
|
||||
if (options.sort !== false) {
|
||||
result.sort((left, right) => left.tool.name.localeCompare(right.tool.name));
|
||||
}
|
||||
const methods = new Map<string, string>();
|
||||
for (const entry of result) {
|
||||
const previous = methods.get(entry.methodName);
|
||||
if (previous) {
|
||||
throw new Error(
|
||||
`Generated proxy method collision '${entry.methodName}' for tools '${previous}' and '${entry.tool.name}'.`
|
||||
);
|
||||
}
|
||||
methods.set(entry.methodName, entry.tool.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildEmbeddedSchemaMap(tools: ToolMetadata[]): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const entry of tools) {
|
||||
for (const entry of tools.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name))) {
|
||||
if (entry.tool.inputSchema && typeof entry.tool.inputSchema === 'object') {
|
||||
result[entry.tool.name] = entry.tool.inputSchema;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { MCPORTER_VERSION } from '../runtime.js';
|
||||
import { MCPORTER_VERSION } from '../version.js';
|
||||
import { boldText, dimText, extraDimText, supportsAnsiColor } from './terminal.js';
|
||||
|
||||
type HelpEntry = {
|
||||
@ -57,11 +57,31 @@ function buildCommandSections(colorize: boolean): string[] {
|
||||
summary: 'Call a tool by selector (server.tool) or HTTP URL; key=value flags supported',
|
||||
usage: 'mcporter call <selector> [key=value ...]',
|
||||
},
|
||||
{
|
||||
name: 'resource',
|
||||
summary: 'List or read MCP resources exposed by a server',
|
||||
usage: 'mcporter resource <server> [uri]',
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
summary: 'Complete OAuth for a server without listing tools',
|
||||
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>]',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -102,6 +122,11 @@ function buildCommandSections(colorize: boolean): string[] {
|
||||
summary: 'Manage the keep-alive daemon (start | status | stop | restart)',
|
||||
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>]',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -139,7 +164,7 @@ function formatGlobalFlags(colorize: boolean): string {
|
||||
},
|
||||
{
|
||||
flag: '--oauth-timeout <ms>',
|
||||
summary: 'Time to wait for browser-based OAuth before giving up (default 60000)',
|
||||
summary: 'Time to wait for browser-based OAuth before giving up (default 300000)',
|
||||
},
|
||||
];
|
||||
const formatted = entries.map((entry) => ` ${entry.flag.padEnd(34)}${entry.summary}`);
|
||||
|
||||
@ -49,6 +49,18 @@ export async function handleInspectCli(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function printInspectCliHelp(): void {
|
||||
console.error(
|
||||
[
|
||||
'Usage: mcporter inspect-cli <artifact> [flags]',
|
||||
'',
|
||||
'Flags:',
|
||||
' --json Print embedded metadata as JSON.',
|
||||
' --format text|json Choose output format.',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function parseInspectFlags(args: string[]): InspectFlags {
|
||||
let format = consumeOutputFormat(args, {
|
||||
defaultFormat: 'text',
|
||||
@ -81,6 +93,9 @@ function parseInspectFlags(args: string[]): InspectFlags {
|
||||
if (!artifactPath) {
|
||||
throw new Error('Usage: mcporter inspect-cli <artifact> [--json]');
|
||||
}
|
||||
if (args.length > 0) {
|
||||
throw new Error(`Unexpected inspect-cli argument '${args[0]}'.`);
|
||||
}
|
||||
return { artifactPath, format };
|
||||
}
|
||||
|
||||
|
||||
@ -1,101 +1,44 @@
|
||||
import ora from 'ora';
|
||||
import type { ServerDefinition } from '../config.js';
|
||||
import { MCPORTER_VERSION } from '../runtime.js';
|
||||
import { setStdioLogMode } from '../sdk-patches.js';
|
||||
import type { EphemeralServerSpec } from './adhoc-server.js';
|
||||
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
||||
import { prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||
import { MCPORTER_VERSION } from '../version.js';
|
||||
import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||
import { splitHttpToolSelector } from './http-utils.js';
|
||||
import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from './identifier-helpers.js';
|
||||
import { formatExampleBlock } from './list-detail-helpers.js';
|
||||
import type { ListSummaryResult, StatusCategory } from './list-format.js';
|
||||
import { classifyListError, formatSourceSuffix, renderServerListRow } from './list-format.js';
|
||||
import { extractListFlags } from './list-flags.js';
|
||||
import type { ToolMetadata } from './generate/tools.js';
|
||||
import {
|
||||
buildAuthCommandHint,
|
||||
buildJsonListEntry,
|
||||
createEmptyStatusCounts,
|
||||
createUnknownResult,
|
||||
type ListJsonServerEntry,
|
||||
printBriefTool,
|
||||
printSingleServerHeader,
|
||||
printToolDetail,
|
||||
summarizeStatusCounts,
|
||||
} from './list-output.js';
|
||||
import { consumeOutputFormat } from './output-format.js';
|
||||
import { dimText, extraDimText, supportsSpinner, yellowText } from './terminal.js';
|
||||
import { consumeTimeoutFlag, LIST_TIMEOUT_MS, withTimeout } from './timeouts.js';
|
||||
import { LIST_TIMEOUT_MS, withTimeout } from './timeouts.js';
|
||||
import { loadToolMetadata } from './tool-cache.js';
|
||||
import { formatTransportSummary } from './transport-utils.js';
|
||||
|
||||
export function extractListFlags(args: string[]): {
|
||||
schema: boolean;
|
||||
timeoutMs?: number;
|
||||
requiredOnly: boolean;
|
||||
ephemeral?: EphemeralServerSpec;
|
||||
format: ListOutputFormat;
|
||||
verbose: boolean;
|
||||
includeSources: boolean;
|
||||
} {
|
||||
let schema = false;
|
||||
let timeoutMs: number | undefined;
|
||||
let requiredOnly = true;
|
||||
let verbose = false;
|
||||
let includeSources = false;
|
||||
const format = consumeOutputFormat(args, {
|
||||
defaultFormat: 'text',
|
||||
allowed: ['text', 'json'],
|
||||
enableRawShortcut: false,
|
||||
jsonShortcutFlag: '--json',
|
||||
}) as ListOutputFormat;
|
||||
const ephemeral = extractEphemeralServerFlags(args);
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (token === '--schema') {
|
||||
schema = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--yes') {
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--all-parameters') {
|
||||
requiredOnly = false;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--verbose') {
|
||||
verbose = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--sources') {
|
||||
includeSources = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose, includeSources };
|
||||
}
|
||||
|
||||
type ListOutputFormat = 'text' | 'json';
|
||||
|
||||
export async function handleList(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
args: string[]
|
||||
): Promise<void> {
|
||||
const flags = extractListFlags(args);
|
||||
let target = args.shift();
|
||||
let requestedTool: string | undefined;
|
||||
|
||||
if (target) {
|
||||
const split = splitHttpToolSelector(target);
|
||||
if (split) {
|
||||
target = split.baseUrl;
|
||||
requestedTool = split.tool;
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +50,9 @@ export async function handleList(
|
||||
target = prepared.target;
|
||||
|
||||
if (!target) {
|
||||
if (flags.brief) {
|
||||
throw new Error('--brief requires a server target.');
|
||||
}
|
||||
const previousStdioLogMode = setStdioLogMode('silent');
|
||||
try {
|
||||
const servers = runtime.getDefinitions();
|
||||
@ -114,6 +60,9 @@ export async function handleList(
|
||||
const perServerTimeoutSeconds = Math.round(perServerTimeoutMs / 1000);
|
||||
|
||||
if (servers.length === 0) {
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
if (flags.format === 'json') {
|
||||
const payload = {
|
||||
mode: 'list',
|
||||
@ -127,17 +76,17 @@ export async function handleList(
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.format === 'text') {
|
||||
if (!flags.quiet && flags.format === 'text') {
|
||||
console.log(
|
||||
`mcporter ${MCPORTER_VERSION} — Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`
|
||||
);
|
||||
}
|
||||
const spinner =
|
||||
flags.format === 'text' && supportsSpinner
|
||||
!flags.quiet && flags.format === 'text' && supportsSpinner
|
||||
? ora(`Discovering ${servers.length} server(s)…`).start()
|
||||
: undefined;
|
||||
const renderedResults =
|
||||
flags.format === 'text'
|
||||
!flags.quiet && flags.format === 'text'
|
||||
? (Array.from({ length: servers.length }, () => undefined) as Array<
|
||||
ReturnType<typeof renderServerListRow> | undefined
|
||||
>)
|
||||
@ -149,28 +98,7 @@ export async function handleList(
|
||||
let completedCount = 0;
|
||||
|
||||
const tasks = servers.map((server, index) =>
|
||||
(async (): Promise<ListSummaryResult> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const tools = await withTimeout(
|
||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
|
||||
perServerTimeoutMs
|
||||
);
|
||||
return {
|
||||
server,
|
||||
status: 'ok' as const,
|
||||
tools,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
server,
|
||||
status: 'error' as const,
|
||||
error,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
})().then((result) => {
|
||||
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
|
||||
summaryResults[index] = result;
|
||||
if (renderedResults) {
|
||||
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
||||
@ -193,20 +121,25 @@ export async function handleList(
|
||||
);
|
||||
|
||||
await Promise.all(tasks);
|
||||
const jsonEntries = summaryResults.map((entry, index) => {
|
||||
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
|
||||
if (!serverDefinition) {
|
||||
throw new Error('Unable to resolve server definition for JSON output.');
|
||||
}
|
||||
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
|
||||
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
|
||||
includeSchemas: Boolean(flags.schema),
|
||||
includeSources: Boolean(flags.verbose || flags.includeSources),
|
||||
});
|
||||
});
|
||||
const counts = summarizeStatusCounts(jsonEntries);
|
||||
maybeSetListExitCode(jsonEntries, flags);
|
||||
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.format === 'json') {
|
||||
const jsonEntries = summaryResults.map((entry, index) => {
|
||||
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
|
||||
if (!serverDefinition) {
|
||||
throw new Error('Unable to resolve server definition for JSON output.');
|
||||
}
|
||||
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
|
||||
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
|
||||
includeSchemas: Boolean(flags.schema),
|
||||
includeSources: Boolean(flags.verbose || flags.includeSources),
|
||||
});
|
||||
});
|
||||
const counts = summarizeStatusCounts(jsonEntries);
|
||||
console.log(JSON.stringify({ mode: 'list', counts, servers: jsonEntries }, null, 2));
|
||||
return;
|
||||
}
|
||||
@ -214,21 +147,13 @@ export async function handleList(
|
||||
if (spinner) {
|
||||
spinner.stop();
|
||||
}
|
||||
const errorCounts = createEmptyStatusCounts();
|
||||
renderedResults?.forEach((entry) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const category = entry.category ?? 'error';
|
||||
errorCounts[category] = (errorCounts[category] ?? 0) + 1;
|
||||
});
|
||||
const okSummary = `${errorCounts.ok} healthy`;
|
||||
const okSummary = `${counts.ok} healthy`;
|
||||
const parts = [
|
||||
okSummary,
|
||||
...(errorCounts.auth > 0 ? [`${errorCounts.auth} auth required`] : []),
|
||||
...(errorCounts.offline > 0 ? [`${errorCounts.offline} offline`] : []),
|
||||
...(errorCounts.http > 0 ? [`${errorCounts.http} http errors`] : []),
|
||||
...(errorCounts.error > 0 ? [`${errorCounts.error} errors`] : []),
|
||||
...(counts.auth > 0 ? [`${counts.auth} auth required`] : []),
|
||||
...(counts.offline > 0 ? [`${counts.offline} offline`] : []),
|
||||
...(counts.http > 0 ? [`${counts.http} http errors`] : []),
|
||||
...(counts.error > 0 ? [`${counts.error} errors`] : []),
|
||||
];
|
||||
console.log(`✔ Listed ${servers.length} server${servers.length === 1 ? '' : 's'} (${parts.join('; ')}).`);
|
||||
return;
|
||||
@ -237,8 +162,20 @@ export async function handleList(
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveServerDefinition(runtime, target);
|
||||
if (!requestedTool) {
|
||||
const selector = resolveConfiguredToolSelector(runtime, target);
|
||||
if (selector) {
|
||||
target = selector.server;
|
||||
requestedTool = selector.tool;
|
||||
}
|
||||
}
|
||||
if (flags.statusOnly && requestedTool) {
|
||||
throw new Error('--status cannot be used with a tool selector.');
|
||||
}
|
||||
|
||||
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
|
||||
if (!resolved) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
target = resolved.name;
|
||||
@ -250,104 +187,269 @@ export async function handleList(
|
||||
: undefined;
|
||||
const transportSummary = formatTransportSummary(definition);
|
||||
const startedAt = Date.now();
|
||||
if (flags.format === 'json') {
|
||||
if (flags.statusOnly) {
|
||||
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
||||
try {
|
||||
const metadataEntries = await withTimeout(loadToolMetadata(runtime, target, { includeSchema: true }), timeoutMs);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: 'ok' as StatusCategory,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
tools: metadataEntries.map((entry) => ({
|
||||
name: entry.tool.name,
|
||||
description: entry.tool.description,
|
||||
inputSchema: entry.tool.inputSchema,
|
||||
outputSchema: entry.tool.outputSchema,
|
||||
options: entry.options,
|
||||
})),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
} catch (error) {
|
||||
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;
|
||||
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 {
|
||||
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
|
||||
const metadataEntries = await withTimeout(loadToolMetadata(runtime, target, { includeSchema: true }), timeoutMs);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const summaryLine = printSingleServerHeader(
|
||||
definition,
|
||||
metadataEntries.length,
|
||||
durationMs,
|
||||
transportSummary,
|
||||
sourcePath,
|
||||
{
|
||||
printSummaryNow: false,
|
||||
if (flags.format === 'json') {
|
||||
try {
|
||||
const metadataEntries = filterToolMetadata(
|
||||
await withTimeout(
|
||||
loadToolMetadata(runtime, target, {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: flags.disableOAuth,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
requestedTool
|
||||
);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (requestedTool && metadataEntries.length === 0) {
|
||||
if (!flags.quiet) {
|
||||
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const instructions = await loadServerInstructions(runtime, target);
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: 'ok' as StatusCategory,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
instructions,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
tools: metadataEntries.map((entry) => ({
|
||||
name: entry.tool.name,
|
||||
description: entry.tool.description,
|
||||
inputSchema: entry.tool.inputSchema,
|
||||
outputSchema: entry.tool.outputSchema,
|
||||
options: entry.options,
|
||||
})),
|
||||
};
|
||||
if (!flags.quiet) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
const payload = {
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: advice.category,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
issue: advice.issue,
|
||||
authCommand: advice.authCommand,
|
||||
error: advice.summary,
|
||||
};
|
||||
if (!flags.quiet) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
|
||||
const metadataEntries = filterToolMetadata(
|
||||
await withTimeout(
|
||||
loadToolMetadata(runtime, target, {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: flags.disableOAuth,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
requestedTool
|
||||
);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (requestedTool && metadataEntries.length === 0) {
|
||||
if (!flags.quiet) {
|
||||
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
const instructions = await loadServerInstructions(runtime, target);
|
||||
const summaryLine = printSingleServerHeader(
|
||||
definition,
|
||||
metadataEntries.length,
|
||||
durationMs,
|
||||
transportSummary,
|
||||
sourcePath,
|
||||
{
|
||||
printSummaryNow: false,
|
||||
instructions,
|
||||
}
|
||||
);
|
||||
if (metadataEntries.length === 0) {
|
||||
console.log(' Tools: <none>');
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
if (flags.brief) {
|
||||
let optionalOmitted = false;
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printBriefTool(definition, entry, flags.requiredOnly);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
if (flags.requiredOnly && optionalOmitted) {
|
||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||
console.log('');
|
||||
}
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
const examples: string[] = [];
|
||||
let optionalOmitted = false;
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
|
||||
examples.push(...detail.examples);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
const uniqueExamples = formatExampleBlock(examples);
|
||||
if (uniqueExamples.length > 0) {
|
||||
console.log(` ${dimText('Examples:')}`);
|
||||
for (const example of uniqueExamples) {
|
||||
console.log(` ${example}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
if (flags.requiredOnly && optionalOmitted) {
|
||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||
console.log('');
|
||||
}
|
||||
);
|
||||
if (metadataEntries.length === 0) {
|
||||
console.log(' Tools: <none>');
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
const examples: string[] = [];
|
||||
let optionalOmitted = false;
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
|
||||
examples.push(...detail.examples);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
const uniqueExamples = formatExampleBlock(examples);
|
||||
if (uniqueExamples.length > 0) {
|
||||
console.log(` ${dimText('Examples:')}`);
|
||||
for (const example of uniqueExamples) {
|
||||
console.log(` ${example}`);
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
maybeSetListExitCode([{ status: 'error' }], flags);
|
||||
if (flags.quiet) {
|
||||
return;
|
||||
}
|
||||
const durationMs = Date.now() - startedAt;
|
||||
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
|
||||
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
|
||||
console.warn(` Reason: ${message}`);
|
||||
if (advice.category === 'auth' && advice.authCommand) {
|
||||
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
if (flags.requiredOnly && optionalOmitted) {
|
||||
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
|
||||
console.log('');
|
||||
} finally {
|
||||
if (previousStdioLogMode !== undefined) {
|
||||
setStdioLogMode(previousStdioLogMode);
|
||||
}
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkListServer(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: ServerDefinition,
|
||||
timeoutMs: number,
|
||||
disableOAuth: boolean
|
||||
): Promise<ListSummaryResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const tools = await withTimeout(
|
||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true, disableOAuth }),
|
||||
timeoutMs
|
||||
);
|
||||
return {
|
||||
server,
|
||||
status: 'ok' as const,
|
||||
tools,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
|
||||
const authCommand = buildAuthCommandHint(definition);
|
||||
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
|
||||
console.warn(` Tools: <timed out after ${timeoutMs}ms>`);
|
||||
console.warn(` Reason: ${message}`);
|
||||
if (advice.category === 'auth' && advice.authCommand) {
|
||||
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
|
||||
}
|
||||
return {
|
||||
server,
|
||||
status: 'error' as const,
|
||||
error,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSetListExitCode(
|
||||
entries: readonly { status: StatusCategory }[],
|
||||
flags: ReturnType<typeof extractListFlags>
|
||||
): void {
|
||||
if (!flags.exitCode) {
|
||||
return;
|
||||
}
|
||||
if (entries.some((entry) => entry.status !== 'ok')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: StatusCategory): string {
|
||||
switch (status) {
|
||||
case 'auth':
|
||||
return 'auth required';
|
||||
case 'offline':
|
||||
return 'offline';
|
||||
case 'http':
|
||||
return 'http error';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'ok':
|
||||
return 'healthy';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,6 +464,7 @@ export function printListHelp(): void {
|
||||
'Ad-hoc servers:',
|
||||
' --http-url <url> Register an HTTP server for this run.',
|
||||
' --allow-http Permit plain http:// URLs with --http-url.',
|
||||
' --header KEY=value Attach HTTP headers (repeatable).',
|
||||
' --stdio <command> Run a stdio MCP server (repeat --stdio-arg for args).',
|
||||
' --stdio-arg <value> Append args to the stdio command (repeatable).',
|
||||
' --env KEY=value Inject env vars for stdio servers (repeatable).',
|
||||
@ -372,16 +475,26 @@ export function printListHelp(): void {
|
||||
' --yes Skip confirmation prompts when persisting.',
|
||||
'',
|
||||
'Display flags:',
|
||||
' --brief Show compact signatures only for a single server.',
|
||||
' --signatures Alias for --brief.',
|
||||
' --schema Show tool schemas when listing servers.',
|
||||
' --all-parameters Include optional parameters in tool docs.',
|
||||
' --json Emit a JSON summary instead of text.',
|
||||
' --status Check server status only, without tool docs.',
|
||||
' --exit-code Exit 1 when any checked server is unhealthy.',
|
||||
' --quiet Suppress output; implies --exit-code.',
|
||||
' --verbose Show all config sources for matching servers.',
|
||||
' --sources Include source arrays in JSON output without other verbose details.',
|
||||
' --timeout <ms> Override the per-server discovery timeout.',
|
||||
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||
'',
|
||||
'Examples:',
|
||||
' mcporter list',
|
||||
' mcporter list --quiet',
|
||||
' mcporter list linear --schema',
|
||||
' mcporter list linear --status --json',
|
||||
' mcporter list linear --brief',
|
||||
' mcporter list linear.list_issues --signatures',
|
||||
' mcporter list https://mcp.example.com/mcp',
|
||||
' mcporter list --http-url https://localhost:3333/mcp --schema',
|
||||
];
|
||||
@ -390,7 +503,8 @@ export function printListHelp(): void {
|
||||
|
||||
function resolveServerDefinition(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
name: string
|
||||
name: string,
|
||||
options: { quiet?: boolean } = {}
|
||||
): { definition: ServerDefinition; name: string } | undefined {
|
||||
try {
|
||||
const definition = runtime.getDefinition(name);
|
||||
@ -401,7 +515,9 @@ function resolveServerDefinition(
|
||||
}
|
||||
const suggestion = suggestServerName(runtime, name);
|
||||
if (!suggestion) {
|
||||
console.error(error.message);
|
||||
if (!options.quiet) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
@ -410,13 +526,17 @@ function resolveServerDefinition(
|
||||
resolution: suggestion,
|
||||
});
|
||||
if (suggestion.kind === 'auto' && messages.auto) {
|
||||
console.log(dimText(messages.auto));
|
||||
return resolveServerDefinition(runtime, suggestion.value);
|
||||
if (!options.quiet) {
|
||||
console.log(dimText(messages.auto));
|
||||
}
|
||||
return resolveServerDefinition(runtime, suggestion.value, options);
|
||||
}
|
||||
if (messages.suggest) {
|
||||
if (!options.quiet && messages.suggest) {
|
||||
console.error(yellowText(messages.suggest));
|
||||
}
|
||||
console.error(error.message);
|
||||
if (!options.quiet) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@ -429,3 +549,82 @@ function suggestServerName(
|
||||
const names = definitions.map((entry) => entry.name);
|
||||
return chooseClosestIdentifier(attempted, names);
|
||||
}
|
||||
|
||||
function resolveConfiguredToolSelector(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
target: string | undefined
|
||||
): { server: string; tool: string } | undefined {
|
||||
if (!target || !target.includes('.')) {
|
||||
return undefined;
|
||||
}
|
||||
const definitions = runtime.getDefinitions();
|
||||
const match = definitions
|
||||
.map((definition) => definition.name)
|
||||
.filter((name) => target.startsWith(`${name}.`))
|
||||
.toSorted((a, b) => b.length - a.length)[0];
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const tool = target.slice(match.length + 1);
|
||||
if (!tool) {
|
||||
return undefined;
|
||||
}
|
||||
return { server: match, tool };
|
||||
}
|
||||
|
||||
function filterToolMetadata(entries: ToolMetadata[], requestedTool: string | undefined): ToolMetadata[] {
|
||||
if (!requestedTool) {
|
||||
return entries;
|
||||
}
|
||||
return entries.filter((entry) => entry.tool.name === requestedTool);
|
||||
}
|
||||
|
||||
function printMissingToolText(
|
||||
definition: ServerDefinition,
|
||||
tool: string,
|
||||
durationMs: number,
|
||||
transportSummary: string,
|
||||
sourcePath: string | undefined
|
||||
): void {
|
||||
printSingleServerHeader(definition, 0, durationMs, transportSummary, sourcePath);
|
||||
console.warn(` Tool '${tool}' not found on '${definition.name}'.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function printMissingToolJson(
|
||||
definition: ServerDefinition,
|
||||
tool: string,
|
||||
durationMs: number,
|
||||
transportSummary: string,
|
||||
flags: { verbose: boolean; includeSources: boolean }
|
||||
): void {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'server',
|
||||
name: definition.name,
|
||||
status: 'error' as StatusCategory,
|
||||
durationMs,
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
|
||||
tools: [],
|
||||
error: `Tool '${tool}' not found on '${definition.name}'.`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function loadServerInstructions(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
serverName: string
|
||||
): Promise<string | undefined> {
|
||||
if (typeof runtime.getInstructions !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
return runtime.getInstructions(serverName);
|
||||
}
|
||||
|
||||
145
src/cli/list-flags.ts
Normal file
145
src/cli/list-flags.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import type { EphemeralServerSpec } from './adhoc-server.js';
|
||||
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
||||
import { consumeOutputFormat } from './output-format.js';
|
||||
import { consumeTimeoutFlag } from './timeouts.js';
|
||||
|
||||
export type ListOutputFormat = 'text' | 'json';
|
||||
|
||||
export function extractListFlags(args: string[]): {
|
||||
schema: boolean;
|
||||
timeoutMs?: number;
|
||||
requiredOnly: boolean;
|
||||
ephemeral?: EphemeralServerSpec;
|
||||
format: ListOutputFormat;
|
||||
verbose: boolean;
|
||||
includeSources: boolean;
|
||||
brief: boolean;
|
||||
quiet: boolean;
|
||||
exitCode: boolean;
|
||||
statusOnly: boolean;
|
||||
disableOAuth: boolean;
|
||||
} {
|
||||
let schema = false;
|
||||
let timeoutMs: number | undefined;
|
||||
let requiredOnly = true;
|
||||
let verbose = false;
|
||||
let includeSources = false;
|
||||
let brief = false;
|
||||
let quiet = false;
|
||||
let exitCode = false;
|
||||
let statusOnly = false;
|
||||
let disableOAuth = false;
|
||||
const format = consumeOutputFormat(args, {
|
||||
defaultFormat: 'text',
|
||||
allowed: ['text', 'json'],
|
||||
enableRawShortcut: false,
|
||||
jsonShortcutFlag: '--json',
|
||||
}) as ListOutputFormat;
|
||||
const ephemeral = extractEphemeralServerFlags(args);
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (token === '--schema') {
|
||||
schema = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--yes') {
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--all-parameters') {
|
||||
requiredOnly = false;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--verbose') {
|
||||
verbose = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--sources') {
|
||||
includeSources = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--brief' || token === '--signatures') {
|
||||
brief = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--quiet') {
|
||||
quiet = true;
|
||||
exitCode = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--exit-code') {
|
||||
exitCode = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--status') {
|
||||
statusOnly = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--no-oauth') {
|
||||
disableOAuth = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if (brief) {
|
||||
const conflicts: string[] = [];
|
||||
if (format === 'json') {
|
||||
conflicts.push('--json');
|
||||
}
|
||||
if (schema) {
|
||||
conflicts.push('--schema');
|
||||
}
|
||||
if (verbose) {
|
||||
conflicts.push('--verbose');
|
||||
}
|
||||
if (!requiredOnly) {
|
||||
conflicts.push('--all-parameters');
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(`--brief cannot be used with ${conflicts.join(', ')}`);
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -13,11 +13,16 @@ export interface ToolDetailResult {
|
||||
optionalOmitted: boolean;
|
||||
}
|
||||
|
||||
export interface ToolBriefResult {
|
||||
optionalOmitted: boolean;
|
||||
}
|
||||
|
||||
export interface ListJsonServerEntry {
|
||||
name: string;
|
||||
status: StatusCategory;
|
||||
durationMs: number;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
transport?: string;
|
||||
source?: ServerDefinition['source'];
|
||||
sources?: ServerDefinition['sources'];
|
||||
@ -38,7 +43,7 @@ export function printSingleServerHeader(
|
||||
durationMs: number | undefined,
|
||||
transportSummary: string,
|
||||
sourcePath: string | undefined,
|
||||
options?: { printSummaryNow?: boolean }
|
||||
options?: { printSummaryNow?: boolean; instructions?: string }
|
||||
): string {
|
||||
const prefix = boldText(definition.name);
|
||||
if (definition.description) {
|
||||
@ -46,6 +51,11 @@ export function printSingleServerHeader(
|
||||
} else {
|
||||
console.log(prefix);
|
||||
}
|
||||
if (options?.instructions) {
|
||||
for (const line of formatInstructionLines(options.instructions)) {
|
||||
console.log(` ${extraDimText(line)}`);
|
||||
}
|
||||
}
|
||||
const summaryParts: string[] = [];
|
||||
summaryParts.push(
|
||||
extraDimText(typeof toolCount === 'number' ? `${toolCount} tool${toolCount === 1 ? '' : 's'}` : 'tools unavailable')
|
||||
@ -69,6 +79,16 @@ export function printSingleServerHeader(
|
||||
return summaryLine;
|
||||
}
|
||||
|
||||
function formatInstructionLines(instructions: string): string[] {
|
||||
const normalized = instructions.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
const maxLength = 500;
|
||||
const clipped = normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}…` : normalized;
|
||||
return [`Instructions: ${clipped}`];
|
||||
}
|
||||
|
||||
export function printToolDetail(
|
||||
definition: ReturnType<Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>['getDefinition']>,
|
||||
metadata: ToolMetadata,
|
||||
@ -106,6 +126,30 @@ export function printToolDetail(
|
||||
};
|
||||
}
|
||||
|
||||
export function printBriefTool(
|
||||
definition: ReturnType<Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>['getDefinition']>,
|
||||
metadata: ToolMetadata,
|
||||
requiredOnly: boolean
|
||||
): ToolBriefResult {
|
||||
const doc = buildToolDoc({
|
||||
serverName: definition.name,
|
||||
toolName: metadata.tool.name,
|
||||
description: metadata.tool.description,
|
||||
outputSchema: metadata.tool.outputSchema,
|
||||
options: metadata.options,
|
||||
requiredOnly,
|
||||
colorize: true,
|
||||
});
|
||||
console.log(` ${doc.signature}`);
|
||||
if (doc.optionalSummary && requiredOnly) {
|
||||
console.log(` ${doc.optionalSummary}`);
|
||||
}
|
||||
console.log('');
|
||||
return {
|
||||
optionalOmitted: doc.hiddenOptions.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExampleOptions(
|
||||
definition: ReturnType<Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>['getDefinition']>
|
||||
): { selector?: string; wrapExpression?: boolean } | undefined {
|
||||
@ -222,5 +266,5 @@ function quoteCommandSegment(segment: string): string {
|
||||
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return JSON.stringify(segment);
|
||||
return `'${segment.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import type { CallResult } from '../result-utils.js';
|
||||
import { logWarn } from './logger-context.js';
|
||||
@ -33,17 +34,8 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
||||
return;
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
if (typeof result === 'string') {
|
||||
const idx = result.indexOf(':');
|
||||
if (idx !== -1) {
|
||||
const candidate = result.slice(idx + 1).trim();
|
||||
if (candidate) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result && typeof result === 'object') {
|
||||
const possibleKeys = ['logPath', 'logFile', 'logfile', 'path'];
|
||||
const possibleKeys = ['logPath', 'logFile', 'logfile'];
|
||||
for (const key of possibleKeys) {
|
||||
const value = (result as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string') {
|
||||
@ -53,6 +45,10 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
logWarn(`Refusing to tail non-absolute log path: ${candidate}`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) {
|
||||
logWarn(`Log path not found: ${candidate}`);
|
||||
continue;
|
||||
|
||||
150
src/cli/record-command.ts
Normal file
150
src/cli/record-command.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
ensurePrivateRecordingDir,
|
||||
PRIVATE_RECORDING_FILE_MODE,
|
||||
resolveRecordingConfigPath,
|
||||
resolveRecordingPath,
|
||||
} from '../runtime/record-transport.js';
|
||||
import { buildRecordCommandEnv } from './record-replay-env.js';
|
||||
|
||||
export interface ParsedRecordArgs {
|
||||
readonly sessionName: string;
|
||||
readonly server?: string;
|
||||
readonly command: string[];
|
||||
}
|
||||
|
||||
export async function handleRecordCli(args: string[]): Promise<void> {
|
||||
const parsed = parseRecordArgs(args);
|
||||
const recordPath = resolveRecordingPath(parsed.sessionName);
|
||||
|
||||
if (parsed.command.length > 0) {
|
||||
await runWithRecordingEnv(parsed, buildRecordCommandEnv(parsed.sessionName, parsed.server));
|
||||
return;
|
||||
}
|
||||
|
||||
await writeModeConfig(parsed, {
|
||||
mode: 'record',
|
||||
recordPath,
|
||||
env: {
|
||||
MCPORTER_RECORD: parsed.sessionName,
|
||||
...(parsed.server ? { MCPORTER_RECORD_SERVER: parsed.server } : {}),
|
||||
MCPORTER_DISABLE_KEEPALIVE: '*',
|
||||
},
|
||||
});
|
||||
console.log(`Recording configuration written to ${resolveRecordingConfigPath(parsed.sessionName)}`);
|
||||
const envInstructions = [
|
||||
`MCPORTER_RECORD=${parsed.sessionName}`,
|
||||
...(parsed.server ? [`MCPORTER_RECORD_SERVER=${parsed.server}`] : []),
|
||||
'MCPORTER_DISABLE_KEEPALIVE=*',
|
||||
];
|
||||
console.log(`Set ${envInstructions.join(' and ')} before the next mcporter call to record ${recordPath}.`);
|
||||
}
|
||||
|
||||
export function printRecordHelp(): void {
|
||||
console.log(`Usage: mcporter record <session-name> [--server <name>] [-- <command-to-run>]
|
||||
|
||||
Capture MCP JSON-RPC traffic to ~/.mcporter/recordings/<session-name>.ndjson.
|
||||
|
||||
Flags:
|
||||
--server <name> Restrict recording to one configured server.`);
|
||||
}
|
||||
|
||||
export function parseRecordArgs(args: string[]): ParsedRecordArgs {
|
||||
return parseSessionCommandArgs(args, 'record');
|
||||
}
|
||||
|
||||
export function parseReplayArgs(args: string[]): ParsedRecordArgs {
|
||||
return parseSessionCommandArgs(args, 'replay');
|
||||
}
|
||||
|
||||
async function writeModeConfig(parsed: ParsedRecordArgs, extra: Record<string, unknown>): Promise<void> {
|
||||
const configPath = resolveRecordingConfigPath(parsed.sessionName);
|
||||
await ensurePrivateRecordingDir(configPath);
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
session: parsed.sessionName,
|
||||
server: parsed.server,
|
||||
...extra,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: PRIVATE_RECORDING_FILE_MODE,
|
||||
}
|
||||
);
|
||||
await fs.chmod(configPath, PRIVATE_RECORDING_FILE_MODE);
|
||||
}
|
||||
|
||||
async function runWithRecordingEnv(parsed: ParsedRecordArgs, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const [command, ...commandArgs] = parsed.command;
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, commandArgs, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`Command '${command}' exited from signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 0;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseSessionCommandArgs(args: string[], commandName: 'record' | 'replay'): ParsedRecordArgs {
|
||||
let server: string | undefined;
|
||||
const tokens = [...args];
|
||||
const commandSeparator = tokens.indexOf('--');
|
||||
const command = commandSeparator === -1 ? [] : tokens.splice(commandSeparator);
|
||||
if (command[0] === '--') {
|
||||
command.shift();
|
||||
}
|
||||
|
||||
const remaining: string[] = [];
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token === '--server') {
|
||||
const value = tokens[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--server' requires a server name.");
|
||||
}
|
||||
server = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--server=')) {
|
||||
server = token.slice('--server='.length);
|
||||
if (!server) {
|
||||
throw new Error("Flag '--server' requires a server name.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
throw new Error(`Unknown ${commandName} flag '${token}'.`);
|
||||
}
|
||||
remaining.push(token);
|
||||
}
|
||||
|
||||
const sessionName = remaining[0];
|
||||
if (!sessionName) {
|
||||
throw new Error(`Usage: mcporter ${commandName} <session-name> [--server <name>] [-- <command-to-run>]`);
|
||||
}
|
||||
if (remaining.length > 1) {
|
||||
throw new Error(`Unexpected ${commandName} argument '${remaining[1]}'. Put commands after '--'.`);
|
||||
}
|
||||
return { sessionName, server, command };
|
||||
}
|
||||
46
src/cli/record-replay-env.ts
Normal file
46
src/cli/record-replay-env.ts
Normal file
@ -0,0 +1,46 @@
|
||||
const KEEP_ALIVE_DISABLED_FOR_MODE = '*';
|
||||
|
||||
export function buildRecordCommandEnv(sessionName: string, server: string | undefined): NodeJS.ProcessEnv {
|
||||
return buildModeEnv(
|
||||
{
|
||||
MCPORTER_RECORD: sessionName,
|
||||
MCPORTER_RECORD_SERVER: server,
|
||||
MCPORTER_DISABLE_KEEPALIVE: KEEP_ALIVE_DISABLED_FOR_MODE,
|
||||
},
|
||||
['MCPORTER_REPLAY', 'MCPORTER_REPLAY_SERVER']
|
||||
);
|
||||
}
|
||||
|
||||
export function buildReplayCommandEnv(sessionName: string, server: string | undefined): NodeJS.ProcessEnv {
|
||||
return buildModeEnv(
|
||||
{
|
||||
MCPORTER_REPLAY: sessionName,
|
||||
MCPORTER_REPLAY_SERVER: server,
|
||||
MCPORTER_DISABLE_KEEPALIVE: KEEP_ALIVE_DISABLED_FOR_MODE,
|
||||
},
|
||||
['MCPORTER_RECORD', 'MCPORTER_RECORD_SERVER']
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecordReplayModeActive(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.MCPORTER_RECORD || env.MCPORTER_REPLAY);
|
||||
}
|
||||
|
||||
export function isReplayModeActive(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(!env.MCPORTER_RECORD && env.MCPORTER_REPLAY);
|
||||
}
|
||||
|
||||
function buildModeEnv(set: Record<string, string | undefined>, unset: readonly string[]): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
for (const key of unset) {
|
||||
delete env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(set)) {
|
||||
if (value) {
|
||||
env[key] = value;
|
||||
} else {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
84
src/cli/replay-command.ts
Normal file
84
src/cli/replay-command.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
ensurePrivateRecordingDir,
|
||||
PRIVATE_RECORDING_FILE_MODE,
|
||||
resolveRecordingConfigPath,
|
||||
resolveRecordingPath,
|
||||
} from '../runtime/record-transport.js';
|
||||
import { parseReplayArgs } from './record-command.js';
|
||||
import { buildReplayCommandEnv } from './record-replay-env.js';
|
||||
|
||||
export async function handleReplayCli(args: string[]): Promise<void> {
|
||||
const parsed = parseReplayArgs(args);
|
||||
const replayPath = resolveRecordingPath(parsed.sessionName);
|
||||
|
||||
if (parsed.command.length > 0) {
|
||||
await runWithReplayEnv(parsed.command, buildReplayCommandEnv(parsed.sessionName, parsed.server));
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = resolveRecordingConfigPath(parsed.sessionName);
|
||||
await ensurePrivateRecordingDir(configPath);
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
session: parsed.sessionName,
|
||||
server: parsed.server,
|
||||
mode: 'replay',
|
||||
replayPath,
|
||||
env: {
|
||||
MCPORTER_REPLAY: parsed.sessionName,
|
||||
...(parsed.server ? { MCPORTER_REPLAY_SERVER: parsed.server } : {}),
|
||||
MCPORTER_DISABLE_KEEPALIVE: '*',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: PRIVATE_RECORDING_FILE_MODE,
|
||||
}
|
||||
);
|
||||
await fs.chmod(configPath, PRIVATE_RECORDING_FILE_MODE);
|
||||
console.log(`Replay configuration written to ${configPath}`);
|
||||
const envInstructions = [
|
||||
`MCPORTER_REPLAY=${parsed.sessionName}`,
|
||||
...(parsed.server ? [`MCPORTER_REPLAY_SERVER=${parsed.server}`] : []),
|
||||
'MCPORTER_DISABLE_KEEPALIVE=*',
|
||||
];
|
||||
console.log(`Set ${envInstructions.join(' and ')} before the next mcporter call to replay ${replayPath}.`);
|
||||
}
|
||||
|
||||
export function printReplayHelp(): void {
|
||||
console.log(`Usage: mcporter replay <session-name> [--server <name>] [-- <command-to-run>]
|
||||
|
||||
Replay MCP JSON-RPC traffic from ~/.mcporter/recordings/<session-name>.ndjson.
|
||||
|
||||
Flags:
|
||||
--server <name> Restrict replay to one configured server.`);
|
||||
}
|
||||
|
||||
async function runWithReplayEnv(commandAndArgs: string[], env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const [command, ...args] = commandAndArgs;
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`Command '${command}' exited from signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 0;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
84
src/cli/resource-command.ts
Normal file
84
src/cli/resource-command.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { analyzeConnectionError } from '../error-classifier.js';
|
||||
import { wrapCallResult } from '../result-utils.js';
|
||||
import { buildConnectionIssueEnvelope, formatErrorMessage } from './json-output.js';
|
||||
import { consumeOutputFormat } from './output-format.js';
|
||||
import { printCallOutput } from './output-utils.js';
|
||||
|
||||
type Runtime = Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>;
|
||||
|
||||
export async function handleResource(runtime: Runtime, args: string[]): Promise<void> {
|
||||
const output = consumeOutputFormat(args, {
|
||||
defaultFormat: 'auto',
|
||||
allowed: ['auto', 'text', 'markdown', 'json', 'raw'],
|
||||
enableRawShortcut: true,
|
||||
jsonShortcutFlag: '--json',
|
||||
});
|
||||
const disableOAuth = consumeDisableOAuthFlag(args);
|
||||
const server = args.shift();
|
||||
if (!server) {
|
||||
throw new Error('Missing server name. Usage: mcporter resource <server> [uri]');
|
||||
}
|
||||
const uri = args.shift();
|
||||
if (args.length > 0) {
|
||||
throw new Error(`Unexpected resource arguments: ${args.join(' ')}`);
|
||||
}
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
if (disableOAuth === undefined) {
|
||||
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
|
||||
} else {
|
||||
const connectOptions = { disableOAuth };
|
||||
result = uri
|
||||
? await runtime.readResource(server, uri, connectOptions)
|
||||
: await runtime.listResources(server, connectOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
const issue = analyzeConnectionError(error);
|
||||
if (output === 'json' || output === 'raw') {
|
||||
console.log(JSON.stringify(buildConnectionIssueEnvelope({ server, error, issue }), null, 2));
|
||||
} else {
|
||||
console.error(`[mcporter] ${formatErrorMessage(error)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { callResult } = wrapCallResult(result);
|
||||
printCallOutput(callResult, result, output);
|
||||
}
|
||||
|
||||
function consumeDisableOAuthFlag(args: string[]): boolean | undefined {
|
||||
let disableOAuth: boolean | undefined;
|
||||
for (let index = 0; index < args.length; ) {
|
||||
const token = args[index];
|
||||
if (token === '--no-oauth') {
|
||||
disableOAuth = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return disableOAuth;
|
||||
}
|
||||
|
||||
export function printResourceHelp(): void {
|
||||
console.error(
|
||||
[
|
||||
'Usage: mcporter resource <server> [uri] [flags]',
|
||||
'',
|
||||
'Without a URI, lists resources exposed by the server.',
|
||||
'With a URI, reads that MCP resource and prints text/markdown/json content when possible.',
|
||||
'',
|
||||
'Flags:',
|
||||
' --output auto|text|markdown|json|raw Choose output rendering.',
|
||||
' --json Shortcut for --output json.',
|
||||
' --raw Shortcut for --output raw.',
|
||||
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||
'',
|
||||
'Examples:',
|
||||
' mcporter resource docs',
|
||||
' mcporter resource docs file:///repo/README.md',
|
||||
' mcporter resource docs greeting://Peter --output text',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
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_CALL_TIMEOUT_MS = 60_000;
|
||||
const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
|
||||
|
||||
export function parsePositiveInteger(raw: string | undefined): number | undefined {
|
||||
if (!raw || !POSITIVE_INTEGER_PATTERN.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
// parseTimeout reads timeout values from strings while honoring defaults.
|
||||
export function parseTimeout(raw: string | undefined, fallback: number): number {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
return parsePositiveInteger(raw) ?? fallback;
|
||||
}
|
||||
|
||||
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
|
||||
@ -58,8 +63,8 @@ export function consumeTimeoutFlag(
|
||||
if (!value) {
|
||||
throw new Error(missingValueMessage);
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const parsed = parsePositiveInteger(value);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
|
||||
}
|
||||
args.splice(index, 2);
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import type { Runtime } from '../runtime.js';
|
||||
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
|
||||
import type { ListToolsOptions, Runtime } from '../runtime.js';
|
||||
import { buildToolMetadataList, type ToolMetadata } from './generate/tools.js';
|
||||
|
||||
interface LoadToolMetadataOptions {
|
||||
includeSchema?: boolean;
|
||||
autoAuthorize?: boolean;
|
||||
allowCachedAuth?: boolean;
|
||||
disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
||||
@ -11,7 +13,9 @@ const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>(
|
||||
function cacheKey(serverName: string, options: LoadToolMetadataOptions): string {
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}`;
|
||||
const allowCachedAuth = options.allowCachedAuth !== false;
|
||||
const disableOAuth = options.disableOAuth === true;
|
||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}::disable-oauth:${disableOAuth ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
export async function loadToolMetadata(
|
||||
@ -31,9 +35,15 @@ export async function loadToolMetadata(
|
||||
}
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
const listOptions: ListToolsOptions = {
|
||||
includeSchema,
|
||||
autoAuthorize,
|
||||
allowCachedAuth: options.allowCachedAuth ?? true,
|
||||
disableOAuth: options.disableOAuth,
|
||||
};
|
||||
const promise = runtime
|
||||
.listTools(serverName, { includeSchema, autoAuthorize })
|
||||
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
|
||||
.listTools(serverName, listOptions)
|
||||
.then((tools) => buildToolMetadataList(tools, { sort: false }))
|
||||
.catch((error) => {
|
||||
cache?.delete(key);
|
||||
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,5 +1,15 @@
|
||||
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
|
||||
import { expandHome } from './env.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
CommandSpec,
|
||||
RawEntry,
|
||||
RawRefresh,
|
||||
RefreshableBearerOptions,
|
||||
ServerDefinition,
|
||||
ServerLoggingOptions,
|
||||
ServerSource,
|
||||
} from './config-schema.js';
|
||||
import { expandHome, resolveEnvPlaceholders } from './env.js';
|
||||
import { resolveLifecycle } from './lifecycle.js';
|
||||
|
||||
export function normalizeServerEntry(
|
||||
@ -9,19 +19,28 @@ export function normalizeServerEntry(
|
||||
source: ServerSource,
|
||||
sources: readonly ServerSource[]
|
||||
): ServerDefinition {
|
||||
const resolvedRaw = resolveConfigEnvPlaceholders(name, raw);
|
||||
raw = resolvedRaw;
|
||||
const description = raw.description;
|
||||
const env = raw.env ? { ...raw.env } : undefined;
|
||||
const auth = normalizeAuth(raw.auth);
|
||||
const tokenCacheDir = normalizePath(raw.tokenCacheDir ?? raw.token_cache_dir);
|
||||
const clientName = raw.clientName ?? raw.client_name;
|
||||
const oauthClientId = raw.oauthClientId ?? raw.oauth_client_id ?? undefined;
|
||||
const oauthClientSecret = raw.oauthClientSecret ?? raw.oauth_client_secret ?? undefined;
|
||||
const oauthClientSecretEnv = raw.oauthClientSecretEnv ?? raw.oauth_client_secret_env ?? undefined;
|
||||
const oauthTokenEndpointAuthMethod =
|
||||
raw.oauthTokenEndpointAuthMethod ?? raw.oauth_token_endpoint_auth_method ?? undefined;
|
||||
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? 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 oauthCommand = oauthCommandRaw ? { args: [...oauthCommandRaw.args] } : undefined;
|
||||
const headers = buildHeaders(raw);
|
||||
|
||||
const httpUrl = getUrl(raw);
|
||||
const stdio = getCommand(raw);
|
||||
const stdio = getCommand(raw, baseDir);
|
||||
|
||||
let command: CommandSpec;
|
||||
|
||||
@ -36,7 +55,7 @@ export function normalizeServerEntry(
|
||||
kind: 'stdio',
|
||||
command: stdio.command,
|
||||
args: stdio.args,
|
||||
cwd: baseDir,
|
||||
cwd: resolveCwd(raw.cwd, baseDir),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Server '${name}' is missing a baseUrl/url or command definition in mcporter.json`);
|
||||
@ -44,6 +63,8 @@ export function normalizeServerEntry(
|
||||
|
||||
const lifecycle = resolveLifecycle(name, raw.lifecycle, command);
|
||||
const logging = normalizeLogging(raw.logging);
|
||||
const allowedTools = raw.allowedTools ?? raw.allowed_tools;
|
||||
const blockedTools = raw.blockedTools ?? raw.blocked_tools;
|
||||
|
||||
const defaultedOauthCommand =
|
||||
!oauthCommand && name.toLowerCase() === 'gmail' && command.kind === 'stdio'
|
||||
@ -58,20 +79,75 @@ export function normalizeServerEntry(
|
||||
auth,
|
||||
tokenCacheDir,
|
||||
clientName,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
oauthClientSecretEnv,
|
||||
oauthTokenEndpointAuthMethod,
|
||||
oauthRedirectUrl,
|
||||
oauthScope,
|
||||
oauthCommand: defaultedOauthCommand,
|
||||
refresh,
|
||||
httpFetch,
|
||||
source,
|
||||
sources,
|
||||
lifecycle,
|
||||
logging,
|
||||
...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}),
|
||||
...(blockedTools !== undefined ? { blockedTools: [...blockedTools] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const __configInternals = {
|
||||
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 {
|
||||
if (!auth) {
|
||||
return undefined;
|
||||
@ -79,9 +155,31 @@ function normalizeAuth(auth: string | undefined): string | undefined {
|
||||
if (auth.toLowerCase() === 'oauth') {
|
||||
return 'oauth';
|
||||
}
|
||||
if (auth.toLowerCase() === 'refreshable_bearer') {
|
||||
return 'refreshable_bearer';
|
||||
}
|
||||
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 {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
@ -89,11 +187,18 @@ function normalizePath(input: string | undefined): string | undefined {
|
||||
return expandHome(input);
|
||||
}
|
||||
|
||||
function resolveCwd(input: string | undefined, baseDir: string): string {
|
||||
if (!input) {
|
||||
return baseDir;
|
||||
}
|
||||
return path.resolve(baseDir, expandHome(input));
|
||||
}
|
||||
|
||||
function getUrl(raw: RawEntry): string | undefined {
|
||||
return raw.baseUrl ?? raw.base_url ?? raw.url ?? raw.serverUrl ?? raw.server_url ?? undefined;
|
||||
}
|
||||
|
||||
function getCommand(raw: RawEntry): { command: string; args: string[] } | undefined {
|
||||
function getCommand(raw: RawEntry, baseDir: string): { command: string; args: string[] } | undefined {
|
||||
const commandValue = raw.command ?? raw.executable;
|
||||
if (Array.isArray(commandValue)) {
|
||||
if (commandValue.length === 0 || typeof commandValue[0] !== 'string') {
|
||||
@ -106,6 +211,9 @@ function getCommand(raw: RawEntry): { command: string; args: string[] } | undefi
|
||||
if (args.length > 0) {
|
||||
return { command: commandValue, args };
|
||||
}
|
||||
if (isExistingCommandPath(commandValue, baseDir)) {
|
||||
return { command: commandValue, args: [] };
|
||||
}
|
||||
const tokens = parseCommandString(commandValue);
|
||||
if (tokens.length === 0) {
|
||||
return undefined;
|
||||
@ -119,6 +227,33 @@ function getCommand(raw: RawEntry): { command: string; args: string[] } | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isExistingCommandPath(value: string, baseDir: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.includes(' ')) {
|
||||
return false;
|
||||
}
|
||||
if (!looksLikePath(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
const expanded = expandHome(trimmed);
|
||||
const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(baseDir, expanded);
|
||||
try {
|
||||
return fs.statSync(resolved).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
return (
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('/') ||
|
||||
value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
value.startsWith('~/')
|
||||
);
|
||||
}
|
||||
|
||||
function buildHeaders(raw: RawEntry): Record<string, string> | undefined {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
|
||||
@ -34,6 +34,8 @@ const RawLifecycleSchema = z
|
||||
|
||||
export type RawLifecycle = z.infer<typeof RawLifecycleSchema>;
|
||||
|
||||
const ToolNamesSchema = z.array(z.string()).describe('Exact MCP tool names');
|
||||
|
||||
const RawLoggingSchema = z
|
||||
.object({
|
||||
daemon: z
|
||||
@ -46,6 +48,37 @@ const RawLoggingSchema = z
|
||||
.optional()
|
||||
.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
|
||||
.object({
|
||||
description: z.string().optional().describe('Human-readable description of the server'),
|
||||
@ -60,19 +93,39 @@ export const RawEntrySchema = z
|
||||
.describe('Command to spawn for stdio transport (string or array of arguments)'),
|
||||
executable: z.string().optional().describe('Executable path for stdio transport'),
|
||||
args: z.array(z.string()).optional().describe('Arguments to pass to the stdio command'),
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Working directory for stdio servers. A leading ~ is expanded to $HOME; relative paths resolve against the config file directory'
|
||||
),
|
||||
headers: z
|
||||
.record(z.string(), z.string())
|
||||
.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
|
||||
.record(z.string(), z.string())
|
||||
.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")'),
|
||||
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)'),
|
||||
clientName: z.string().optional().describe('Client identifier for server telemetry (camelCase)'),
|
||||
client_name: z.string().optional().describe('Client identifier for server telemetry (snake_case)'),
|
||||
oauthClientId: z.string().optional().describe('Pre-registered OAuth client id (camelCase)'),
|
||||
oauth_client_id: z.string().optional().describe('Pre-registered OAuth client id (snake_case)'),
|
||||
oauthClientSecret: z.string().optional().describe('Pre-registered OAuth client secret (camelCase)'),
|
||||
oauth_client_secret: z.string().optional().describe('Pre-registered OAuth client secret (snake_case)'),
|
||||
oauthClientSecretEnv: z.string().optional().describe('Environment variable containing the OAuth client secret'),
|
||||
oauth_client_secret_env: z.string().optional().describe('Environment variable containing the OAuth client secret'),
|
||||
oauthTokenEndpointAuthMethod: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('OAuth token endpoint auth method, e.g. client_secret_post'),
|
||||
oauth_token_endpoint_auth_method: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('OAuth token endpoint auth method, e.g. client_secret_post'),
|
||||
oauthRedirectUrl: z.string().optional().describe('Custom OAuth redirect URL (camelCase)'),
|
||||
oauth_redirect_url: z.string().optional().describe('Custom OAuth redirect URL (snake_case)'),
|
||||
oauthScope: z.string().optional().describe('OAuth scope override (camelCase)'),
|
||||
@ -96,14 +149,44 @@ export const RawEntrySchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.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(),
|
||||
logging: RawLoggingSchema,
|
||||
allowedTools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (camelCase)'),
|
||||
allowed_tools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (snake_case)'),
|
||||
blockedTools: ToolNamesSchema.optional().describe('These exact tool names are hidden and blocked (camelCase)'),
|
||||
blocked_tools: ToolNamesSchema.optional().describe('These exact tool names are hidden and blocked (snake_case)'),
|
||||
})
|
||||
.superRefine((entry, ctx) => {
|
||||
const hasAllowed = entry.allowedTools !== undefined || entry.allowed_tools !== undefined;
|
||||
const hasBlocked = entry.blockedTools !== undefined || entry.blocked_tools !== undefined;
|
||||
if (hasAllowed && hasBlocked) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Specify either allowedTools or blockedTools, not both.',
|
||||
path: ['allowedTools'],
|
||||
});
|
||||
}
|
||||
})
|
||||
.describe('MCP server definition supporting both HTTP/SSE and stdio transports');
|
||||
|
||||
export const RawConfigSchema = z
|
||||
.object({
|
||||
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
|
||||
daemonIdleTimeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
|
||||
daemon_idle_timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
|
||||
imports: z
|
||||
.array(ImportKindSchema)
|
||||
.optional()
|
||||
@ -113,6 +196,7 @@ export const RawConfigSchema = z
|
||||
|
||||
export type RawEntry = z.infer<typeof RawEntrySchema>;
|
||||
export type RawConfig = z.infer<typeof RawConfigSchema>;
|
||||
export type RawRefresh = z.infer<typeof RawRefreshSchema>;
|
||||
|
||||
export interface HttpCommand {
|
||||
readonly kind: 'http';
|
||||
@ -150,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 {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
@ -158,15 +251,25 @@ export interface ServerDefinition {
|
||||
readonly auth?: string;
|
||||
readonly tokenCacheDir?: string;
|
||||
readonly clientName?: string;
|
||||
readonly oauthClientId?: string;
|
||||
readonly oauthClientSecret?: string;
|
||||
readonly oauthClientSecretEnv?: string;
|
||||
readonly oauthTokenEndpointAuthMethod?: string;
|
||||
readonly oauthRedirectUrl?: string;
|
||||
readonly oauthScope?: string;
|
||||
readonly oauthCommand?: {
|
||||
readonly args: string[];
|
||||
};
|
||||
readonly refresh?: RefreshableBearerOptions;
|
||||
readonly httpFetch?: 'default' | 'node-http1';
|
||||
readonly source?: ServerSource;
|
||||
readonly sources?: readonly ServerSource[];
|
||||
readonly lifecycle?: ServerLifecycle;
|
||||
readonly logging?: ServerLoggingOptions;
|
||||
/** When specified, only these exact tool names are exposed. Empty array blocks all tools. */
|
||||
readonly allowedTools?: readonly string[];
|
||||
/** When specified, these exact tool names are hidden and blocked. Cannot be combined with allowedTools. */
|
||||
readonly blockedTools?: readonly string[];
|
||||
}
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user