Compare commits

...

78 Commits

Author SHA1 Message Date
Peter Steinberger
fe87142d89
fix(daemon): preserve replacement socket ownership
Some checks are pending
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
2026-06-25 13:46:49 -07:00
Vincent Koc
782e028abe
test: make metadata fixture executable on Windows (#220)
Some checks are pending
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
* test: make metadata fixture executable on Windows

* test: use node executable for metadata fixture

* ci: avoid macos tsgolint crash
2026-06-25 15:00:52 +08:00
Peter Steinberger
2a9b353b21
chore(deps): update dependencies
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-06-23 20:13:48 +01:00
Vincent Koc
f02bef36d2
test: skip unusable Bun compile probes
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
2026-06-22 14:36:58 +08:00
Vincent Koc
7491ed5a85
fix: harden CLI parsing and generated artifacts 2026-06-22 14:03:17 +08:00
Vincent Koc
8beee8764f
chore(release): prepare 0.12.1
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-06-18 15:50:14 +08:00
Peter Steinberger
c1b58296db
feat: support file-backed call arguments (#213) 2026-06-18 07:54:31 +02:00
Loveacup
6f3f42ca42
fix: skip imported servers with unresolvable env placeholders (#209)
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
* fix: skip imported servers with unresolvable env placeholders

When importing MCP server configs from external sources (Cursor, Claude),
some entries reference editor-specific variables like ${workspaceFolder}
that are not available as environment variables outside the editor.

Previously, normalizeServerEntry would throw on unresolvable placeholders,
crashing the entire config loading. Now imported servers that fail to
resolve are silently skipped; locally-defined servers still fail fast.

Fixes: Cursor mcp.json with codegraph server using ${workspaceFolder}
prevents mcporter from loading any servers.

* fix(config): fall back from invalid imported duplicates

---------

Co-authored-by: Loveacup <loveacup@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 17:32:49 +08:00
Krasimir Kralev
53747cac63
fix(oauth): degrade to re-auth on corrupt credential cache instead of crashing (#208)
* fix(oauth): degrade to re-auth on corrupt credential cache instead of crashing

DirectoryPersistence.readTokens/readClientInfo/readState routed a corrupt
(truncated or malformed) cache file's JSON.parse SyntaxError straight up
through CompositePersistence, crashing the MCP connection instead of degrading
to re-auth. Every sibling reader already tolerates this: VaultPersistence
(oauth-vault.ts) catches SyntaxError to rebuild, and the daemon/server-proxy
readers wrap in catch-all. These three were the lone outliers.

Wrap the three JSON readers in a narrow helper that maps only SyntaxError to
undefined; genuine I/O faults still propagate. readJsonFile is left untouched so
the vault keeps distinguishing corrupt-from-missing for its repair path.

Fixes #207.

* fix(oauth): keep corrupt OAuth state failing closed (addresses Codex P2)

Codex review on #208 noted that making readState() corrupt-tolerant skips the
CSRF state check on the authorization callback: oauth.ts only rejects when the
stored expectedState is truthy, so a corrupt state.txt degrading to undefined
would let a mismatched/absent callback state through.

Narrow the tolerance to the credential caches only (readTokens/readClientInfo).
readState() keeps throwing on corrupt input so the OAuth flow fails closed.
Test now asserts state reads reject with SyntaxError while tokens/client degrade.

* test(oauth): preserve callback cleanup for read errors

---------

Co-authored-by: KrasimirKralev <krasi@idrobots.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 17:32:24 +08:00
Vincent Koc
4037f0a064
fix(deps): remediate Dependabot alerts (#211) 2026-06-17 10:07:12 +08:00
Peter Steinberger
37391ce70b
chore(release): start 0.12.1
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-06-10 06:52:15 +01:00
Peter Steinberger
023314cf31
chore(release): prepare 0.12.0 2026-06-10 06:44:28 +01:00
Peter Steinberger
f2f67b4a38
chore(pnpm): move overrides to workspace config 2026-06-10 06:44:21 +01:00
Peter Steinberger
870df28717
fix(fs): serialize same-process file locks 2026-06-10 06:44:18 +01:00
Sebastian B Otaegui
c9325a6a4a
docs(readme): mention callOnce disableOAuth for headless callers (#205)
Document the existing callOnce disableOAuth option in the public README.\n\nCo-authored-by: Sebastian Otaegui <feniix@gmail.com>
2026-06-09 22:19:23 -07:00
Qi Zhang
4813cdfe7a
fix(cli): keep CloudBase authentication alive (#193)
Keep CloudBase device-code polling alive after `AUTH_PENDING` while preserving explicit ephemeral lifecycle overrides.

Co-authored-by: Qi Zhang <sevenzhang51@gmail.com>
2026-06-09 22:07:47 -07:00
Sebastian B Otaegui
3e27b64021
fix(runtime): preserve disableOAuth across headless paths (#198)
Some checks failed
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
* feat(runtime): add `disableOAuth` connect option (cache-friendly OAuth suppression)

Closes #197.

Long-running headless callers (daemons, scheduled jobs, CI workers) need
to suppress the interactive OAuth flow without losing connection caching.
The only existing knob — `maxOAuthAttempts: 0` — couples those two concerns
because `useCache` is gated on `options.maxOAuthAttempts === undefined`.
Daemons that wrap `connect` to force `maxOAuthAttempts: 0` end up spawning
a fresh transport per `callTool`/`listTools` and `runtime.close()` cannot
reap any of them.

Add an additive `disableOAuth: boolean` option that suppresses OAuth at
the transport layer (short-circuits `shouldEstablishOAuth` and
`maybePromoteHttpDefinition`) but preserves caching. The cache entry
metadata gains a `disableOAuth` field so connections established with
the flag don't share a slot with connections that could refresh into an
OAuth flow — switching the flag between calls evicts and re-establishes,
mirroring the existing `allowCachedAuth` mismatch path.

Backward compatibility:

* `maxOAuthAttempts: 0` keeps its legacy escape-the-cache contract
  unchanged. Existing callers see no behavior change.
* `skipCache: true` keeps its behavior unchanged.
* `disableOAuth` defaults to undefined; only opt-in changes behavior.

Also export `ConnectOptions` from `runtime.ts` and add the parameter to
the `Runtime.connect` interface signature — the implementation already
accepted options at runtime but the interface only exposed
`connect(server)`, so callers couldn't pass options through the type
system. (Pre-existing gap surfaced by adding the new test coverage.)

Tests added to `tests/runtime-integration.test.ts`:

* `reuses cached connection when disableOAuth: true is passed` — two
  calls return the same ClientContext, `close()` reaps it.
* `maxOAuthAttempts: 0 still bypasses the cache (existing contract
  preserved)` — regression guard.
* `evicts and re-establishes the cached client when disableOAuth flag
  changes` — the core eviction semantic.

`pnpm test` (709 pass / 3 skip), `pnpm lint`, `pnpm typecheck` all
green.

* fix(runtime): preserve disableOAuth across helper calls

* fix(daemon): forward disableOAuth through keep-alive paths

* feat(cli): expose disableOAuth for headless commands

* fix(runtime): preserve cached slot across connect(disableOAuth) → callTool/listTools

Addresses PR #198 review comment r3366238654.

The documented headless setup is:

    await runtime.connect(server, { disableOAuth: true });
    await runtime.callTool(server, 'foo', { ... });

The first call stored the cache slot with `allowCachedAuth: undefined`,
but `callTool()` internally calls `this.connect(server, {
allowCachedAuth: true, disableOAuth: <effective>: true })` and the
cache-match check treated the two options shapes as structurally
different:

    existing.allowCachedAuth (undefined)
       !== options.allowCachedAuth (true)
       && options.allowCachedAuth !== undefined
    => MISMATCH => evict + reopen transport

Every first callTool / listTools after a pre-connect spawned a fresh
transport, defeating the pooling guarantee that motivated the
disableOAuth option in the first place. Same shape affected `listTools`
(which defaults `allowCachedAuth: options.allowCachedAuth ?? true`).

Fix: normalize at the connect() entrypoint. A `disableOAuth: true`
caller has no path to interactive OAuth, so cached-token application
is the only auth they can ever use — default `allowCachedAuth: true`
when the caller didn't pick a side. Explicit `false` is honored
(header-only / anonymous callers). The normalized value flows through
both the cache lookup and the cache write so subsequent internal
callers compose without eviction.

Two regression tests added to `tests/runtime-integration.test.ts`:

  - `preserves the cached client across connect(disableOAuth:true) →
    callTool() (no implicit eviction)`
  - `preserves the cached client across connect(disableOAuth:true) →
    listTools() (no implicit eviction)`

Both call `runtime.connect(disableOAuth:true)`, then invoke the
internal-cached path (callTool or listTools), then re-call
`runtime.connect(disableOAuth:true)` and assert the resulting
ClientContext is `=== ` the first one. Both tests fail without this
fix (the second connect returns a new ClientContext because the first
was evicted).

`pnpm test` 723 pass / 3 skip / 0 fail. `pnpm lint` + `pnpm
typecheck` clean. No push.

* docs(examples): add headless-pooling-demo for disableOAuth verification

Demonstrates the three patterns under the new `disableOAuth` option
against a local mock MCP server (no real auth). Reproducible artifact
for PR #198 review proof.

Patterns demonstrated:

* Legacy `maxOAuthAttempts: 0` (uncached): 5 connect() calls produce
  5 distinct ClientContexts. Existing contract preserved.
* `disableOAuth: true` on every connect: 5 calls produce 1
  ClientContext. Cache reuse under cache-friendly suppression.
* Documented headless setup — pre-connect(disableOAuth: true) +
  5 callTool() — proves the pre-connected slot survives the implicit
  internal connect path. Directly demonstrates the fix from b0e3e2e.

Run: `pnpm tsx examples/headless-pooling-demo.ts`

Sample output is intentionally redacted to no PII / no secrets: a local
http://127.0.0.1:<random-port>/mcp server with a public `add` tool.

* style(examples): oxfmt headless-pooling-demo (CI fix)

* fix(server-proxy): thread disableOAuth through schema-discovery listTools

Addresses PR #198 review comment r3366307210 (clawsweeper proxy gap).

The Proxy returned by `createServerProxy` calls `ensureMetadata()` on
every tool invocation, which fires `runtime.listTools(server, {
includeSchema: true })` for schema discovery. That call ran BEFORE the
proxy parsed the caller's options bag, so a `proxy.tool({ ... }, {
disableOAuth: true })` invocation on an OAuth server with no cached
schema could still trigger an interactive OAuth flow during metadata
fetch — defeating the no-browser guarantee the option was meant to
provide.

Fix:

* Pre-scan callArgs once for `disableOAuth: true` before invoking
  `ensureMetadata`. The scan is a single linear pass over the
  already-present argument list and short-circuits on the first match.
* Extend `ensureMetadata(toolName, { disableOAuth? })` and forward the
  flag to the underlying `runtime.listTools(serverName, { includeSchema:
  true, disableOAuth: true })` call.
* The schema-fetch path that was vulnerable now inherits the same
  no-OAuth posture as the eventual `runtime.callTool` invocation. End-
  to-end no-browser guarantee is preserved across the proxy interface.

Regression test in `tests/server-proxy.test.ts`:

  > threads disableOAuth through schema discovery so
  > proxy.tool({disableOAuth:true}) cannot trigger OAuth during
  > metadata fetch

Asserts BOTH:
- `runtime.listTools` called with `{ includeSchema: true, disableOAuth:
  true }`
- `runtime.callTool` called with the eventual tool args and
  `disableOAuth: true`

Locks the contract on both halves so a future refactor that re-introduces
the gap on either side will fail loudly.

Full suite: 724 pass / 3 skipped / 0 fail. `pnpm check` (format + lint
+ typecheck) clean.

* refactor(cli): drop --disable-oauth alias; keep only --no-oauth

The PR originally exposed two CLI names for the same intent:
--disable-oauth (mirroring the JS option `disableOAuth: true`) and
--no-oauth (the GNU-style boolean opt-out). Two names for one
behavior is noise — documentation has to mention both, users have to
learn both, and they invite drift.

--no-oauth is the right shape for a per-invocation boolean opt-out:
- Matches the dominant unix convention (git --no-verify, npm --no-save,
  bun --no-cache, curl --no-progress-meter).
- Shorter to type.
- Composes naturally with other flags in scripts.

The JS option name stays `disableOAuth: boolean` — that's the right
shape for a JS option (verb+noun, no Boolean-negation prefix
ambiguity), and the JS and CLI naming conventions are genuinely
different domains.

Removed CLI registrations + help text + internal forwarding for
--disable-oauth across:
- src/cli/call-arguments.ts (FLAG_HANDLERS registration)
- src/cli/call-command.ts (internal listArgs forwarding, 2 sites)
- src/cli/call-help.ts (help text)
- src/cli/list-command.ts (help text)
- src/cli/list-flags.ts (token check)
- src/cli/resource-command.ts (token check + help text)
- docs/cli-reference.md (3 references)

Renamed test cases that exclusively exercised --disable-oauth to
exercise --no-oauth instead, preserving regression coverage:
- tests/call-arguments.test.ts
- tests/cli-list-flags.test.ts
- tests/cli-resource-command.test.ts

The internal cache-key fragment `disable-oauth:` in
src/cli/tool-cache.ts is kept — it mirrors the JS option name (which
stays `disableOAuth`), not the CLI flag.

Tests: 724 passed, 3 skipped, 0 failed.
Lint: 0 warnings, 0 errors.
Typecheck: clean.

* fix(runtime): forward disableOAuth through callOnce

* chore: update dependencies

* fix(server-proxy): preserve schema-owned option fields

* fix(runtime): isolate OAuth cache variants safely

* fix(server-proxy): isolate schema discovery posture

* fix(server-proxy): preserve OAuth posture during discovery

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-08 16:11:23 -07:00
Peter Steinberger
8f74252a4d
chore(deps): refresh development tooling 2026-06-08 21:29:50 +01:00
Peter Steinberger
0fb13581fb docs(serve): document per-server HTTP endpoints 2026-06-08 12:16:38 -07:00
zm2231
2c04671b92 feat(serve): expose per-server endpoints at /mcp/<server> with unprefixed tools
Add per-server HTTP routes alongside the aggregate /mcp endpoint. /mcp/<server>
serves a single server's tools under their original (unprefixed) names so the
server can be registered under its own client key, while /mcp keeps the
server__tool namespacing. Unknown servers 404; malformed paths 400.
2026-06-08 12:16:38 -07:00
Peter Steinberger
14ff39a59b fix(cli): fail unknown list targets 2026-06-08 12:05:42 -07:00
Lil Z
68b228943c
fix(daemon): stop direct starts orphaning live daemon
Stop direct and foreground daemon starts from orphaning an already-running daemon by validating live daemon metadata, repairing missing metadata, and replacing stale-definition daemons only after they exit.

Proof:
- ./runner pnpm exec vitest run tests/daemon-host.test.ts tests/daemon.integration.test.ts
- ./runner pnpm check
- ./runner pnpm test
- autoreview --mode branch --base origin/main
- PR CI passed on macOS, Ubuntu, Windows, and Socket checks

Co-authored-by: zm2231 <25645999+zm2231@users.noreply.github.com>
2026-06-08 11:35:22 -07:00
Sebastian B Otaegui
f37a642a80
feat(runtime): re-export RuntimeOptions
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
Re-export `RuntimeOptions` from the public package entry so consumers can name the `createRuntime` options shape directly.

Proof:
- `pnpm docs:list`
- `pnpm check`
- `pnpm test` (123 files passed, 1 skipped; 706 tests passed, 3 skipped)
- `pnpm build`
- `rg -n "RuntimeOptions" dist/index.d.ts dist/runtime.d.ts` confirmed the built declaration barrel exports the type.
- Autoreview branch vs `origin/main`: clean, no accepted/actionable findings.
- Existing PR CI green: ubuntu-latest, macos-15, windows-latest.

Co-authored-by: Sebastian Otaegui <feniix@gmail.com>
2026-06-06 22:32:42 -07:00
LDMB123
2bf7a5eab2
fix(replay): rewrite response ids during replay (#192)
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
* feat(record): capture MCP call streams to NDJSON and replay deterministically

mcporter record <session> wraps the runtime transport and appends every
JSON-RPC request, response, and notification to a per-session NDJSON file
under ~/.mcporter/recordings/. mcporter replay <session> reconstructs an
in-memory transport from the recording and matches requests by method +
deep-equal params, returning the recorded response without contacting
the live server.

Use cases:
- Reproduce MCP-backed agent bugs offline (no live Linear quota, no
  Vercel API rate limits)
- Build test fixtures from real call sequences
- Share a session for a postmortem without sharing credentials

The format is plain JSON-RPC over NDJSON with a small _meta field
(direction, server, timestamp). No proprietary blob. Env-var passthrough
(MCPORTER_RECORD=<name>, MCPORTER_REPLAY=<name>) lets the existing
runtime constructor wrap any transport when set.

* fix(replay): attach cause to wrapped errors to satisfy preserve-caught-error lint

* fix(replay): rewrite response ids during replay

* fix(replay): harden record replay modes

Clear conflicting record/replay env vars when spawning wrapped commands, force those commands off the daemon fast path, truncate each recording file at session start, and fail replay close when recorded requests remain unused.

* fix(cli): preserve wrapped command flags

Stop global flag extraction at -- so record/replay wrappers do not consume child command flags, and drop the release-owned changelog entry from the PR diff.

* fix(replay): propagate cleanup failures through cli

Ensure replay-mode transport close failures escape normal runtime and CLI cleanup after best-effort shutdown has completed. Add runtime and CLI regressions for partial recordings that leave requests unreplayed.

* fix: harden record replay runtime paths

* test: align replay fixtures with windows home

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 08:52:02 +01:00
Peter Steinberger
56be50f763
fix(daemon): keep stdio list requests warm
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
2026-05-29 05:18:32 +01:00
Peter Steinberger
815016a008
docs: position README banner
Some checks are pending
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
2026-05-28 20:48:14 +01:00
Vincent Koc
b86eec0b7f
ci: pin macOS runner labels 2026-05-28 20:53:18 +02:00
Peter Steinberger
fb3f041339
docs: add README banner 2026-05-28 19:43:30 +01:00
Peter Steinberger
f4f209317f
fix: reconcile daemon lifecycle starts 2026-05-28 16:45:31 +01:00
Vincent Koc
552fcb1f60
fix(security): update vulnerable dependencies
Some checks are pending
CI / build (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
2026-05-28 11:50:00 +02:00
Peter Steinberger
49dc62b9ee
fix: harden OAuth vault recovery (#190)
Some checks failed
CI / build (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
* fix: harden OAuth vault recovery

* style: format OAuth recovery code
2026-05-26 15:47:31 +01:00
Vincent Koc
1c5e96483e
chore: add constrained Crabbox setup
Some checks failed
CI / build (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
Adds constrained Crabbox setup and the exact OpenClaw Crabbox skill for maintainer validation.
2026-05-23 05:59:27 +08:00
Peter Steinberger
0c36a6d3f8
docs: start 0.11.4 changelog
Some checks failed
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
pages / Deploy docs (push) Has been cancelled
2026-05-21 22:34:46 +01:00
Peter Steinberger
94e65ba057
chore: release 0.11.3 2026-05-21 22:28:27 +01:00
Peter Steinberger
67e3f5250f
fix: fall back to legacy config after empty xdg home (#185) 2026-05-21 22:15:13 +01:00
Peter Steinberger
82b19535d8
docs: start 0.11.3 changelog 2026-05-21 21:33:06 +01:00
Peter Steinberger
9ec79f2b80
docs: correct 0.11.2 release date 2026-05-21 21:28:06 +01:00
Peter Steinberger
348483ea9f
docs: release 0.11.2 changelog
Some checks failed
CI / build (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-05-20 20:59:41 +01:00
Peter Steinberger
de7c811271
feat: add list health check flags (#183) 2026-05-20 20:55:21 +01:00
Peter Steinberger
a1201d1955
fix: support daemon idle timeout config 2026-05-20 17:34:42 +01:00
Peter Steinberger
31bbaa804f
fix: preserve valid cached OAuth tokens 2026-05-20 17:21:05 +01:00
Peter Steinberger
3ca4b5bae8
Merge pull request #178 from asim48-ctrl/codex/document-oauth-process-manager-auth
docs: clarify headless OAuth process lifetime
2026-05-20 17:11:55 +01:00
Peter Steinberger
e6e9675519
docs: update changelog for daemon OAuth fix 2026-05-20 17:10:52 +01:00
Peter Steinberger
ccfaa2f4f0
Merge pull request #182 from bradhallett/fix/daemon-allowCachedAuth
fix(daemon): pass allowCachedAuth to runtime for OAuth token reuse
2026-05-20 17:10:33 +01:00
Peter Steinberger
86e19f4413
fix: use cached auth for daemon OAuth calls 2026-05-20 17:09:14 +01:00
Peter Steinberger
1948ba7bef
docs: add project vision 2026-05-20 16:53:39 +01:00
Peter Steinberger
524e0a2d2f
fix: make generated cli bundles deterministic 2026-05-20 16:53:37 +01:00
Peter Steinberger
b8909e7cc0
docs: add vision triage guidance 2026-05-20 16:14:06 +01:00
Peter Steinberger
90e8d00f12
docs: clarify triage workflow 2026-05-20 16:12:44 +01:00
Brad Hallett
1e6ce66d22 fix(daemon): pass allowCachedAuth to runtime for OAuth token reuse
The daemon host never passed allowCachedAuth when calling
runtime.callTool() or runtime.listTools(), and the KeepAliveRuntime
callTool wrapper did not forward it to the daemon client either.

Without allowCachedAuth, createClientContext skips
applyCachedAuthIfAvailable, so cached OAuth tokens are never read
and every daemon-managed OAuth server fails after token expiry.

Changes:
- protocol.ts: add allowCachedAuth to CallToolParams
- host.ts: pass allowCachedAuth in callTool and listTools handlers
- runtime-wrapper.ts: forward allowCachedAuth in callTool daemon path

Fixes openclaw/mcporter#181
Related openclaw/mcporter#179
2026-05-20 08:23:26 -04:00
Asim Arshad
8c63bbe81e docs: clarify headless OAuth process lifetime 2026-05-17 00:36:39 +01:00
Peter Steinberger
ae3b83cecb
chore(deps): update dependencies
Some checks failed
CI / build (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
2026-05-15 04:49:37 +01:00
Peter Steinberger
cbd84fd6d2
chore: bump development version to 0.11.2
Some checks failed
CI / build (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
pages / Deploy docs (push) Has been cancelled
2026-05-14 19:47:34 +01:00
Peter Steinberger
5ec589698f
chore: start 0.11.2 changelog 2026-05-14 19:32:40 +01:00
Peter Steinberger
46cc31cafe
fix: harden generated cli bundles 2026-05-14 19:22:37 +01:00
Peter Steinberger
dd000bdec4
chore: open 0.11.1 changelog
Some checks are pending
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-latest) (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-14 18:57:40 +01:00
Peter Steinberger
2ce585a1eb
chore: release 0.11.0 2026-05-14 18:51:20 +01:00
Peter Steinberger
c87150895d
ci: strengthen main workflow 2026-05-14 18:46:22 +01:00
Peter Steinberger
7f1e9a8ce0
feat: support refreshable bearer stdio auth 2026-05-14 18:29:43 +01:00
Peter Steinberger
3e06e582ef
fix: add HTTP fetch compatibility mode 2026-05-14 17:31:36 +01:00
Peter Steinberger
2171c1f209
Merge pull request #171 from feniix/feat/headless-oauth-no-browser
feat: add headless OAuth browser suppression
2026-05-14 16:48:57 +01:00
Peter Steinberger
33afa744e0
fix: preserve headless auth stdout 2026-05-14 16:47:03 +01:00
Peter Steinberger
23565e2166
fix: harden concurrent config writes 2026-05-14 16:32:37 +01:00
Peter Steinberger
8d962fbd79
Merge remote-tracking branch 'origin/main' into feat/mcporter-serve
# Conflicts:
#	CHANGELOG.md
2026-05-14 13:40:29 +01:00
Peter Steinberger
907ba78d98
fix: keep served tool names readable 2026-05-14 13:39:05 +01:00
Peter Steinberger
eee954e4a1
fix: patch chrome-devtools auto-connect hang 2026-05-14 12:51:16 +01:00
zm2231
89f5053c15 fix: disambiguate bridged tool names 2026-05-13 21:01:25 -04:00
zm2231
bfe727150c fix: cover serve daemon edge cases 2026-05-13 20:32:33 -04:00
zm2231
6879a69f49 feat: add mcporter serve bridge 2026-05-13 20:26:12 -04:00
Sebastian Otaegui
7ddb433479
docs: document headless OAuth auth flow 2026-05-12 23:59:45 -03:00
Sebastian Otaegui
b29854ebf2
feat: add no-browser auth CLI contract 2026-05-12 23:59:41 -03:00
Sebastian Otaegui
033abb4358
feat: add OAuth no-browser session support 2026-05-12 23:59:34 -03:00
Peter Steinberger
f9f60d7cc4
fix(oauth): refresh expired cached access tokens
Some checks failed
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-05-11 03:13:09 +01:00
Peter Steinberger
0ea394356f
style: format docs site builder 2026-05-11 03:13:09 +01:00
Peter Steinberger
ed698d9e48
docs: generate llms index
Some checks are pending
CI / build (macos-latest) (push) Waiting to run
CI / build (ubuntu-latest) (push) Waiting to run
CI / build (windows-latest) (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-10 00:42:50 +01:00
Peter Steinberger
a64e29b4fe
feat: add headless OAuth vault seeding
Some checks are pending
CI / build (macos-latest) (push) Waiting to run
CI / build (ubuntu-latest) (push) Waiting to run
CI / build (windows-latest) (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-09 14:55:44 +01:00
Peter Steinberger
ea91086273
fix: resolve config env placeholders 2026-05-09 13:14:09 +01:00
Peter Steinberger
5d71e9ce49
docs: add shell syntax highlighting 2026-05-09 12:40:34 +01:00
180 changed files with 15466 additions and 1643 deletions

View 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
View 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
View 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
View File

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

View File

@ -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: 24
- run: corepack enable
- 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
View 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

View File

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

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules
.pnpm-store
.DS_Store
dist
dist-bun

View File

@ -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.

View File

@ -1,8 +1,90 @@
# mcporter Changelog
## [Unreleased]
## [0.12.1] - 2026-06-18
- Nothing yet.
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
### Config
- Skip imported server entries with unresolvable editor-specific environment placeholders, and allow later valid duplicates to take effect without relaxing validation for local config. (PR #209, thanks @Loveacup)
### OAuth
- Treat corrupt cached OAuth tokens and client metadata as missing so connections can re-authenticate, while keeping corrupt callback state data fail-closed. (Issue #207, thanks @KrasimirKralev)
### Tooling / Dependencies
- Refresh development dependencies and security overrides, including Vite, esbuild, and Hono.
## [0.12.0] - 2026-06-10
### OAuth
- Add cache-friendly `disableOAuth` support across headless runtime, CLI, daemon, proxy, and `callOnce` paths so callers can suppress interactive OAuth without losing connection reuse. (Issues #197, #199, #201, thanks @feniix)
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
- Prevent concurrent OAuth vault updates from briefly exposing empty lock files and losing credential entries under load.
### CLI
- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/<server>`, exposing one keep-alive server with original tool names while preserving aggregate `/mcp` namespacing. (PR #194, thanks @zm2231)
- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123)
- Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231)
- Return a non-zero exit code for explicit `mcporter list <unknown-server>` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674)
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
- Keep CloudBase MCP alive by default so device-code authentication can finish polling and persist credentials after returning `AUTH_PENDING`. (PR #193, thanks @sevzq)
- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr)
### Tooling / Dependencies
- Refresh development dependencies and satisfy the stricter `oxlint` check.
## [0.11.3] - 2026-05-21
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)
## [0.11.2] - 2026-05-21
### CLI
- Add `mcporter list --status`, `--exit-code`, and `--quiet` for concise server health checks without introducing a separate health command.
- Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc)
- Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett)
- Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff)
- Add the documented top-level `daemonIdleTimeoutMs` config to shut down inactive keep-alive daemons. (Issue #174, thanks @jarek083)
## [0.11.1] - 2026-05-14
### CLI
- Make `generate-cli --runtime node --bundle <name>.mjs` emit an ES module bundle with a local `require` shim, fixing `.mjs` artifacts that previously crashed at startup.
- Classify generated `.mjs` and `.cjs` outputs as bundle artifacts in embedded metadata instead of reporting them as binaries.
- Avoid leaving implicit `<server>.ts` template files in the current directory when generating bundle-only artifacts without `--output`.
- Print generated CLI help with a trailing newline so subsequent shell output no longer glues onto the help footer.
- Point generated CLI metadata and npm package metadata at `openclaw/mcporter`.
- Document the existing `generate-cli --timeout`, `--minify`, and `--no-minify` flags in `generate-cli --help`.
- Suppress expected Rolldown unresolved-import warnings for Node built-ins during successful generated CLI bundling.
## [0.11.0] - 2026-05-14
### Config
- Support `auth: "refreshable_bearer"` with explicit `refresh` settings so cached OAuth tokens can be refreshed before HTTP connects or injected into stdio env vars. (Issue #173, thanks @tokyo-s)
- Add `httpFetch: "node-http1"` for HTTP MCP servers whose providers reject Node's built-in `fetch`, and auto-apply it to Sunsama's endpoint. (Issue #158, thanks @mattash)
- Resolve `${VAR}` and `${VAR:-fallback}` placeholders across string-valued server config fields such as `baseUrl`, `command`/`args`, `tokenCacheDir`, and pre-registered OAuth fields while keeping headers/env/bearer-token placeholders lazy until runtime. (PR #161 / issue #157, thanks @zxyasfas)
- Add `mcporter vault set <server>` and `mcporter vault clear <server>` so headless deployments can seed or clear OAuth vault credentials without reproducing mcporter's internal vault-key format. (Issue #156)
### CLI
- Add `mcporter serve`, exposing daemon-managed keep-alive servers as one MCP bridge with readable `server__tool` names for stdio and Streamable HTTP clients. (PR #172, thanks @zm2231)
- Prefer MCP `structuredContent` nested inside JSON-RPC result envelopes so `mcporter call --output json` stays parseable for dual text/structured tool responses. (Issue #168, thanks @mar-zh)
- Serialize read-modify-write config and OAuth vault updates, and write JSON/cache metadata atomically to avoid lost entries under parallel invocations. (Issue #167, thanks @alexminza)
- Patch `chrome-devtools-mcp --autoConnect` launches at runtime so `mcporter call chrome-devtools.list_pages` can keep using a logged-in Chrome profile while upstream DevTools-window detection can hang on busy profiles.
### OAuth
- Add headless OAuth login support via `--no-browser`, `--browser none`, and `MCPORTER_OAUTH_NO_BROWSER`, emitting parseable authorization URLs for remote auth flows. (PR #171 / issue #169, thanks @feniix)
- Proactively complete OAuth for configured HTTP servers that allow unauthenticated `initialize`/`listTools` but require credentials for tool calls, and close the local callback server promptly after browser authorization. (PR #159, thanks @Spacefish)
- Refresh expired cached OAuth access tokens during non-interactive `mcporter list` without opening a browser or clearing cached credentials when refresh fails. (Issue #166, thanks @chrisabad)
## [0.10.2] - 2026-05-09

View File

@ -21,16 +21,17 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr
- **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration.
- **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.10.0
## What's New in 0.11.0
- **Resources.** `mcporter resource <server> [uri]` lists and reads MCP resources, including keep-alive daemon routing.
- **Generated CLI polish.** Keep-alive generated CLIs preserve stdio server state across invocations, and Bun-compiled macOS daemon children launch reliably in the background.
- **Config portability.** mcporter now honors XDG Base Directory env vars while preserving the legacy `~/.mcporter` fallback.
- **OAuth compatibility.** Static OAuth clients are supported via `oauthClientId`, `oauthClientSecretEnv`, and token endpoint auth method overrides.
- **Release confidence.** `0.10.0` is published on npm and Homebrew, and live/published install smokes are green.
- **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
@ -60,6 +61,7 @@ npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz
```
- Add `--json` to emit a machine-readable summary with per-server statuses (auth/offline/http/error counts) and, for single-server runs, the full tool schema payload.
- Add `--status` for a concise single-server status check without tool docs, `--exit-code` to fail when any checked server is unhealthy, or `--quiet` for silent health gates.
- Add `--verbose` to show every config source that registered the server name (primary first), both in text and JSON list output.
You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. Until you persist that definition, you still need to repeat the same URL/stdio flags for `mcporter call`—the printed slug only becomes reusable once you merge it into a config via `--persist` or `mcporter config add` (use `--scope home|project` to pick the write target). Follow up with `mcporter auth https://…` (or the same flag set) to finish OAuth without editing config. Full details live in [docs/adhoc.md](docs/adhoc.md).
@ -141,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q
```bash
npx mcporter call chrome-devtools.take_snapshot
npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")'
npx mcporter call linear.create_comment issueId=LNR-123 body=@comment.md
npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
@ -161,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.
@ -195,6 +201,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
- Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env.
- 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
@ -249,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
@ -391,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" },
},
},
@ -401,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.
- `${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
@ -415,6 +423,8 @@ 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
@ -435,6 +445,44 @@ Providers that do not support dynamic client registration can use a pre-register
Keep client secrets in environment variables or private machine-local configs,
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
@ -444,7 +492,7 @@ mcporter reads exactly one primary config per run. The lookup order is:
1. The path you pass via `--config` (or programmatic `configPath`).
2. The `MCPORTER_CONFIG` environment variable (set it in your shell to apply everywhere).
3. `<root>/config/mcporter.json` inside the current project.
4. `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, otherwise `~/.mcporter/mcporter.json[c]`, if the project file is missing.
4. `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, falling back to `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists and the project file is missing.
All `mcporter config …` mutations write back to whichever file was selected by that order. To manage a system-wide config explicitly, point the CLI at it:
@ -454,7 +502,7 @@ mcporter config --config ~/.mcporter/mcporter.json add global-server https://api
Set `MCPORTER_CONFIG=~/.mcporter/mcporter.json` in your shell profile when you want that file to be the default everywhere (handy for `npx mcporter …` runs).
mcporter honors XDG Base Directory env vars for its own files when those vars are explicitly set: `XDG_CONFIG_HOME` for home configs, `XDG_DATA_HOME` for the OAuth vault, `XDG_CACHE_HOME` for schema caches, and `XDG_STATE_HOME` for daemon/runtime state. If the matching XDG var is unset or relative, mcporter keeps the legacy `~/.mcporter` path. Existing explicit overrides still win.
mcporter honors XDG Base Directory env vars for its own files when those vars are explicitly set: `XDG_CONFIG_HOME` for home configs, `XDG_DATA_HOME` for the OAuth vault, `XDG_CACHE_HOME` for schema caches, and `XDG_STATE_HOME` for daemon/runtime state. If the matching XDG var is unset or relative, mcporter keeps the legacy `~/.mcporter` path. Config discovery is XDG-first but still probes `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists, which keeps embedders from hiding the user registry when they set `XDG_CONFIG_HOME` for another tool. Existing explicit overrides still win.
### Tool Filtering

32
VISION.md Normal file
View 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.

View File

@ -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"
}

View File

@ -70,7 +70,7 @@ Shipping a release means **all** of:
After the release is live, always update the Homebrew tap and re-verify both installers. The tap formula should install the npm `.tgz`, not the Bun-compiled macOS tarball, because `generate-cli --compile` needs the installed package tree so Bun can resolve `mcporter`, `commander`, and related dependencies when compiling from an empty directory. Keep the macOS tarball on the GitHub release as a direct binary asset, but point Homebrew at `mcporter-<version>.tgz`.
1. Update `steipete/homebrew-tap` -> `Formula/mcporter.rb` with:
- URL `https://github.com/steipete/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
- URL `https://github.com/openclaw/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
- SHA256 from `mcporter-<version>.tgz.sha256`
- `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.

View File

@ -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,6 +62,8 @@ The CLI still avoids surprise prompts during `mcporter list`; the upgrade happen
- OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions.
- `mcporter auth` accepts the same `--http-url/--stdio` flags (and even bare URLs), so you can immediately re-run `mcporter auth https://…` after a 401 without touching a config file.
- Use `mcporter auth <url> --no-browser` for human-in-the-loop headless OAuth. Text mode writes only the authorization URL to stdout; `--json --no-browser` writes `authorizationUrl` plus `redirectUrl` as JSON. Keep that URL out of durable CI logs and support bundles. The `mcporter auth` process must keep running until the browser redirects to `redirectUrl`; process managers that capture stdout and then tear down the command can kill the local callback listener before tokens are saved. Use a persistent terminal, `tmux`, or a supervised background process such as `nohup` when completing OAuth outside an interactive shell.
- When opening the URL on a different machine, remember that loopback redirect URLs point at the browser machine unless an SSH tunnel forwards the callback port back to the mcporter process. Use `oauthRedirectUrl` when you need a predictable callback port.
- Nothing is written to disk unless you pass `--persist /path/to/config.json`. When set, we merge the generated definition into that file (creating it if necessary) so future runs can rely on the standard config pipeline. Ad-hoc HTTP headers are persisted with the entry, so placeholders such as `--header 'Authorization=$env:MY_TOKEN'` keep working through the normal config header resolver.
## Safety Nets

View File

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

View File

@ -21,6 +21,10 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- Add `--brief` or `--signatures` with a server or `server.tool` target to keep
the server header/instructions and print compact signatures without doc
comments, examples, or schemas.
- Add `--status` with a server target to print only the concise status row
instead of full tool docs.
- Add `--exit-code` to make the command exit 1 when any checked server is
unhealthy, or `--quiet` to suppress output and imply `--exit-code`.
- Hidden alias: `list-tools` (kept for muscle memory; not advertised in help output).
- Hidden ad-hoc flag aliases: `--sse` for `--http-url`, `--insecure` for `--allow-http` (for plain HTTP testing).
- Flags:
@ -29,7 +33,13 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--signatures` alias for `--brief`.
- `--all-parameters` include every optional parameter in the signature.
- `--schema` pretty-print the JSON schema for each tool.
- `--status` check server status only; cannot be combined with `--brief`,
`--schema`, or `--all-parameters`.
- `--exit-code` exit 1 when any checked server is unhealthy.
- `--quiet` suppress output and exit 1 when any checked server is unhealthy.
- `--timeout <ms>` per-server timeout when enumerating all servers.
- `--no-oauth` never start an interactive OAuth flow; use cached
tokens only while keeping eligible connections pooled.
## `mcporter call <server.tool>`
@ -43,7 +53,10 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--save-images <dir>` persist image content blocks to files under the specified directory.
- `--raw-strings` disable numeric coercion for flag-style and positional values.
- `--no-coerce` disable all flag-style/positional value coercion.
- `key=@path` / `--key @path` read a named UTF-8 string argument from a file; prefix with `@@` for a literal leading `@`.
- `--tail-log` stream tail output when the tool returns log handles.
- `--no-oauth` never start an interactive OAuth flow; use cached
tokens only while keeping eligible connections pooled.
## `mcporter resource <server> [uri]`
@ -55,6 +68,30 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--output auto|text|markdown|json|raw` choose how to render the response.
- `--json` shortcut for `--output json`.
- `--raw` shortcut for `--output raw`.
- `--no-oauth` never start an interactive OAuth flow; use cached
tokens only while keeping eligible connections pooled.
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
- 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`

View File

@ -67,7 +67,7 @@ mcporter now merges home and project config files by default so global servers s
1. If you pass `--config <file>` (or set `--config` programmatically), only that file is used—no merging.
2. If `MCPORTER_CONFIG` is set, only that file is used—no merging.
3. Otherwise, mcporter loads both of these layers (when present):
- `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, otherwise `~/.mcporter/mcporter.json[c]`
- `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` is set, falling back to `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists
- `<root>/config/mcporter.json`
Entries from the project file override entries with the same name from the home file. Each layer still pulls in its own imports before merging.
@ -82,7 +82,7 @@ mcporter honors XDG Base Directory env vars for its own paths when they are expl
| cache | `XDG_CACHE_HOME` | `$XDG_CACHE_HOME/mcporter/<server>/schema.json` | `~/.mcporter/...` |
| state | `XDG_STATE_HOME` | `$XDG_STATE_HOME/mcporter/daemon/...` | `~/.mcporter/daemon` |
Unset, empty, or relative XDG vars fall back to `~/.mcporter` for backwards compatibility. Explicit overrides still win: `--config`/`MCPORTER_CONFIG` for config files, `tokenCacheDir` for per-server OAuth/schema cache directories, and `MCPORTER_DAEMON_DIR` for daemon files.
Unset, empty, or relative XDG vars fall back to `~/.mcporter` for backwards compatibility. For config files only, an absolute `XDG_CONFIG_HOME` is XDG-first but still probes `~/.mcporter/mcporter.json[c]` when no XDG mcporter config exists, so embedders that sandbox unrelated tools with `XDG_CONFIG_HOME` do not accidentally hide the user's registry. Explicit overrides still win: `--config`/`MCPORTER_CONFIG` for config files, `tokenCacheDir` for per-server OAuth/schema cache directories, and `MCPORTER_DAEMON_DIR` for daemon files.
## Discovery & Precedence
@ -148,7 +148,8 @@ 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).
- `--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`
@ -165,6 +166,24 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
- `--env KEY=VAL` entries merge with existing `env` dictionaries if you later persist the same server; nothing is lost when you alternate between CLI flags and JSON edits.
- `--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
mcporter provides a JSON Schema for config file validation and autocompletion. Add the `$schema` property to your config file:
@ -206,7 +225,7 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
| `cwd` | Working directory for stdio servers. A leading `~` is expanded to `$HOME`; relative paths resolve against the config file directory. Defaults to the config file directory when omitted. |
| `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. `mcporter list` can still reuse an existing OAuth token cache for older HTTP entries missing this marker. |
| `auth` | Recognizes `oauth` and `refreshable_bearer`. `oauth` runs the browser/client OAuth flow for HTTP transports; `refreshable_bearer` refreshes cached bearer tokens non-interactively before connecting. |
| `tokenCacheDir` | Directory for OAuth tokens and schema caches; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` or `$XDG_DATA_HOME/mcporter/credentials.json` when set (legacy per-server caches are auto-migrated). Supports `~` expansion. |
| `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. |
@ -215,10 +234,40 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
| `oauthRedirectUrl` | Override the default localhost callback. Required for many pre-registered OAuth apps because the provider must allowlist the exact redirect URI. Also useful when tunneling OAuth through Codespaces or remote dev boxes. |
| `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 dont 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 dont 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 runtimes 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
@ -230,6 +279,7 @@ mcporter normalizes headers to include `Accept: application/json, text/event-str
- Keep `config/mcporter.json` under version control. Encourage contributors to add sensitive data via env vars (`${LINEAR_API_KEY}`) rather than inline secrets.
- 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/`).

View File

@ -8,7 +8,7 @@ read_when:
## Goals
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools`). No extra flags for agents.
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools` or CloudBase device authentication). No extra flags for agents.
- **Shared state:** Multiple CLI invocations/agents within the same user session must reuse the same warm transport so STDIO servers can hold tabs, cookies, and other stateful context.
- **Per-config scope:** The daemon lives under the current user account, keyed by config path (`~/.mcporter/daemon/daemon-<config-hash>.sock` on Unix-like systems, or `$XDG_STATE_HOME/mcporter/daemon/...` when set), and never crosses user boundaries.
- **Resilience:** If the daemon or a keep-alive server crashes, the next CLI call restarts it automatically.
@ -34,7 +34,7 @@ read_when:
- **Keep-alive detection:**
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
- Provide a config-level `defaultKeepAlive` array or `MCPORTER_KEEPALIVE` env var for quick overrides.
- Ship a hardcoded allowlist (initially `chrome-devtools`, `mobile-mcp`, `playwright`) so existing configs benefit immediately; users can opt out per server.
- Ship a hardcoded allowlist (`chrome-devtools`, `mobile-mcp`, `playwright`, `cloudbase`) so existing configs benefit immediately; users can opt out per server.
## CLI Surface
@ -49,7 +49,7 @@ read_when:
- **Auto start:** First call requiring the daemon triggers a lightweight bootstrap (fork/exec via `child_process.spawn` inside the CLI). We ensure the original command waits for the socket to become available (with a short timeout).
- **macOS Bun binaries:** Homebrew/Bun-compiled binaries wrap the detached child launch with `nohup` so the background daemon survives the parent CLI exit on macOS 26.
- **Auto restart:** The client shim treats `ECONNREFUSED`/broken pipe as a signal that the daemon died. It retries once by re-launching the daemon before surfacing the error.
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A global `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A top-level config `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
- **Logging:** Daemon writes structured logs under the daemon runtime directory plus per-server logs for STDIO stderr so users can debug crashing servers.
## Agent Isolation

View File

@ -42,6 +42,7 @@ mcporter leans into the **code-execution-with-MCP** pattern Anthropic recommends
- **Typed clients.** [`mcporter emit-ts`](emit-ts.md) emits `.d.ts` interfaces or a ready-to-run client wrapping `createServerProxy()` so agents call MCP tools with full TypeScript types.
- **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

View File

@ -16,6 +16,7 @@ This file tracks limitations that users regularly run into. Most of these requir
- Ask Supabase to accept the MCP scope or publish their scope list.
- GitHubs MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilots 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

View File

@ -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 }

View File

@ -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`).

View File

@ -35,7 +35,7 @@ Use `createServerProxy(runtime, name)` inside scripts when you want ergonomic ca
2. Automatically merges default values.
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control.
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

View File

@ -12,7 +12,7 @@ This walkthrough assumes you already have an MCP server configured in Cursor, Cl
npx mcporter list
```
You get one row per server with auth status, transport type, and tool count. Add `--json` for machine output, or `--verbose` to see which config files registered each server.
You get one row per server with auth status, transport type, and tool count. Add `--json` for machine output, `--quiet` for a silent health gate, or `--verbose` to see which config files registered each server.
## 2. Inspect a single server
@ -26,6 +26,7 @@ Single-server output reads like a TypeScript header file: dimmed `/** … */` do
- `--all-parameters` — show every optional parameter inline.
- `--schema` — pretty-print the JSON schema for each tool.
- `--json` — machine-readable schema payload.
- `--status` — concise status only, without tool docs.
`mcporter list shadcn.io/api/mcp.getComponents` works too — bare URLs (with or without a `.tool` suffix or scheme) auto-resolve.

52
docs/record-replay.md Normal file
View 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.

View File

@ -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 `--`.

View 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);
});

View File

@ -64,7 +64,7 @@
"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"
@ -74,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"
@ -197,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": [
{
@ -290,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",

View File

@ -1,6 +1,6 @@
{
"name": "mcporter",
"version": "0.10.2",
"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": "git+https://github.com/steipete/mcporter.git"
"url": "git+https://github.com/openclaw/mcporter.git"
},
"bin": {
"mcporter": "dist/cli.js"
@ -61,36 +61,42 @@
"docs:site": "node scripts/build-docs-site.mjs",
"generate:schema": "tsx scripts/generate-json-schema.ts",
"mcporter:list": "pnpm exec tsx src/cli.ts list",
"mcporter:call": "pnpm exec tsx src/cli.ts call"
"mcporter:call": "pnpm exec tsx src/cli.ts call",
"check:changed": "pnpm run check",
"test:changed": "pnpm run test",
"crabbox:hydrate": "crabbox actions hydrate",
"crabbox:run": "crabbox run",
"crabbox:stop": "crabbox stop",
"crabbox:warmup": "crabbox warmup"
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.29.0",
"acorn": "^8.16.0",
"commander": "^14.0.3",
"es-toolkit": "^1.46.1",
"acorn": "^8.17.0",
"commander": "^15.0.0",
"es-toolkit": "^1.48.1",
"jsonc-parser": "^3.3.1",
"ora": "^9.4.0",
"rolldown": "1.0.0-rc.18",
"ora": "^9.4.1",
"rolldown": "1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/estree": "^1.0.8",
"@types/estree": "^1.0.9",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@typescript/native-preview": "7.0.0-dev.20260503.1",
"@vitest/coverage-v8": "^4.1.5",
"bun-types": "^1.3.13",
"@types/node": "^26.0.0",
"@typescript/native-preview": "7.0.0-dev.20260623.1",
"@vitest/coverage-v8": "^4.1.9",
"bun-types": "^1.3.14",
"cross-env": "^10.1.0",
"express": "^5.2.1",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"oxfmt": "^0.56.0",
"oxlint": "^1.71.0",
"oxlint-tsgolint": "^0.23.0",
"rimraf": "^6.1.3",
"tsx": "^4.21.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "8.0.10",
"vitest": "^4.1.5"
"vite": "8.0.16",
"vitest": "^4.1.9"
},
"devEngines": {
"runtime": [
@ -103,12 +109,5 @@
"engines": {
"node": ">=24"
},
"packageManager": "pnpm@10.33.2",
"pnpm": {
"overrides": {
"body-parser": "2.2.1",
"ip-address": "10.1.1",
"vite": "8.0.10"
}
}
"packageManager": "pnpm@10.33.2"
}

1521
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -7,7 +7,7 @@ import { css, faviconSvg, js, preThemeScript, themeToggleHtml } from './docs-sit
const root = process.cwd();
const docsDir = path.join(root, 'docs');
const outDir = path.join(root, 'dist', 'docs-site');
const repoBase = 'https://github.com/steipete/mcporter';
const repoBase = 'https://github.com/openclaw/mcporter';
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : '';
@ -103,8 +103,71 @@ 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();

View File

@ -129,6 +129,12 @@ body:not(.home) .doc>h1:first-child{display:none}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-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)}
@ -238,7 +244,13 @@ if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarFor
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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');

View 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;
}
}

View 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;
}
}

View File

@ -28,6 +28,8 @@ export interface SerializedServerDefinition {
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[];
}
@ -72,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> {
@ -151,6 +163,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
};
@ -173,6 +187,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
};

View File

@ -4,6 +4,7 @@ import { inferCommandRouting } from './cli/command-inference.js';
import { CliUsageError } from './cli/errors.js';
import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js';
import { logError, logInfo } from './cli/logger-context.js';
import { isRecordReplayModeActive, isReplayModeActive } from './cli/record-replay-env.js';
import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
import { resolveConfigPath } from './config/path-discovery.js';
import type { Runtime, RuntimeOptions } from './runtime.js';
@ -139,6 +140,43 @@ 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(
@ -182,14 +220,17 @@ export async function runCli(argv: string[]): Promise<void> {
import('./lifecycle.js'),
]);
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
const keepAliveServers = new Set(
baseRuntime
.getDefinitions()
.filter(isKeepAliveServer)
.map((entry) => entry.name)
);
const recordReplayModeActive = isRecordReplayModeActive();
const keepAliveServers = recordReplayModeActive
? new Set<string>()
: new Set(
baseRuntime
.getDefinitions()
.filter(isKeepAliveServer)
.map((entry) => entry.name)
);
const daemonClient =
keepAliveServers.size > 0
!recordReplayModeActive && keepAliveServers.size > 0
? new DaemonClient({
configPath: configResolution.path,
configExplicit: configResolution.explicit,
@ -198,15 +239,16 @@ export async function runCli(argv: string[]): Promise<void> {
: null;
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {
process.exitCode = inference.exitCode;
return;
}
const resolvedCommand = inference.command;
const resolvedArgs = inference.args;
let primaryError: unknown;
try {
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {
process.exitCode = inference.exitCode;
return;
}
const resolvedCommand = inference.command;
const resolvedArgs = inference.args;
if (resolvedCommand === 'list') {
if (consumeHelpTokens(resolvedArgs)) {
const { printListHelp } = await import('./cli/list-command.js');
@ -243,6 +285,18 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
if (resolvedCommand === 'vault') {
if (consumeHelpTokens(resolvedArgs)) {
const { printVaultHelp } = await import('./cli/vault-command.js');
printVaultHelp();
process.exitCode = 0;
return;
}
const { handleVault } = await import('./cli/vault-command.js');
await handleVault(runtime, resolvedArgs);
return;
}
if (resolvedCommand === 'resource' || resolvedCommand === 'resources') {
if (consumeHelpTokens(resolvedArgs)) {
const { printResourceHelp } = await import('./cli/resource-command.js');
@ -254,46 +308,69 @@ export async function runCli(argv: string[]): Promise<void> {
await importedHandleResource(runtime, resolvedArgs);
return;
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
} catch (error) {
primaryError = error;
throw error;
} finally {
const closeStart = Date.now();
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
}
}
async function closeRuntimeAfterCommand(
runtime: Runtime,
options: { readonly suppressReplayCloseError?: boolean } = {}
): Promise<void> {
const closeStart = Date.now();
let closeError: unknown;
if (DEBUG_HANG) {
logInfo('[debug] beginning runtime.close()');
dumpActiveHandles('before runtime.close');
}
try {
await runtime.close();
if (DEBUG_HANG) {
logInfo('[debug] beginning runtime.close()');
dumpActiveHandles('before runtime.close');
const duration = Date.now() - closeStart;
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
dumpActiveHandles('after runtime.close');
}
try {
await runtime.close();
if (DEBUG_HANG) {
const duration = Date.now() - closeStart;
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
dumpActiveHandles('after runtime.close');
}
} catch (error) {
if (DEBUG_HANG) {
logError('[debug] runtime.close() failed', error);
}
} finally {
terminateChildProcesses('runtime.finally');
// By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles
// (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1.
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
const scheduleForcedExit = () => {
if (shouldForceExit) {
setTimeout(() => {
process.exit(process.exitCode ?? 0);
}, FORCE_EXIT_GRACE_MS);
}
};
if (DEBUG_HANG) {
dumpActiveHandles('after terminateChildProcesses');
scheduleForcedExit();
} else {
setImmediate(scheduleForcedExit);
} catch (error) {
if (DEBUG_HANG) {
logError('[debug] runtime.close() failed', error);
}
if (isReplayModeActive() && !options.suppressReplayCloseError) {
closeError = error;
}
} finally {
terminateChildProcesses('runtime.finally');
// By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles
// (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1.
const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1';
const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1';
const scheduleForcedExit = () => {
if (shouldForceExit) {
setTimeout(() => {
process.exit(process.exitCode ?? 0);
}, FORCE_EXIT_GRACE_MS);
}
};
if (DEBUG_HANG) {
dumpActiveHandles('after terminateChildProcesses');
scheduleForcedExit();
} else {
setImmediate(scheduleForcedExit);
}
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
if (closeError) {
throw closeError;
}
}
function wrapperArgsBeforeSeparator(args: readonly string[]): string[] {
const separatorIndex = args.indexOf('--');
return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex);
}
// main parses CLI flags and dispatches to list/call commands.
@ -333,6 +410,9 @@ async function maybeHandleDaemonFastCall(
configResolution: { path: string; explicit: boolean },
rootDir: string | undefined
): Promise<boolean> {
if (isRecordReplayModeActive()) {
return false;
}
const callArgs = resolveDaemonFastCallArgs(command, args);
if (!callArgs) {
return false;
@ -400,6 +480,7 @@ async function maybeHandleSimpleDaemonFastCall(
tool: parsed.tool,
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
disableOAuth: parsed.disableOAuth,
});
const { callResult } = wrapCallResult(result);
printCallOutput(callResult, result, parsed.output);
@ -426,6 +507,9 @@ function isExplicitNonCallCommand(command: string): boolean {
command === 'resource' ||
command === 'resources' ||
command === 'daemon' ||
command === 'serve' ||
command === 'record' ||
command === 'replay' ||
command === 'config' ||
command === 'emit-ts' ||
command === 'generate-cli' ||
@ -501,6 +585,8 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
server,
includeSchema: options?.includeSchema,
autoAuthorize: options?.autoAuthorize,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
})) as Awaited<ReturnType<Runtime['listTools']>>,
callTool: (server, toolName, options) =>
daemonClient.callTool({
@ -508,9 +594,27 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
tool: toolName,
args: options?.args,
timeoutMs: options?.timeoutMs,
disableOAuth: options?.disableOAuth,
}),
listResources: (server, options) => {
const params: Record<string, unknown> = { ...options };
delete params.allowCachedAuth;
delete params.disableOAuth;
delete params.oauthSessionOptions;
return daemonClient.listResources({
server,
params,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
});
},
readResource: (server, uri, options) =>
daemonClient.readResource({
server,
uri,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
}),
listResources: (server, options) => daemonClient.listResources({ server, params: options ?? {} }),
readResource: (server, uri) => daemonClient.readResource({ server, uri }),
connect: async (server) => {
throw new Error(`Server '${server}' is only available through daemon request methods.`);
},

View File

@ -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 {
@ -48,7 +49,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
};
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
const name = slugify(spec.name ?? canonical ?? inferNameFromUrl(url));
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromUrl(url));
const lifecycle = resolveLifecycle(name, undefined, command);
const definition: ServerDefinition = {
name,
@ -83,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,
@ -108,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 {
@ -204,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 = '';

View File

@ -1,5 +1,6 @@
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';
@ -9,12 +10,23 @@ import { extractEphemeralServerFlags } from './ephemeral-flags.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) {
@ -49,13 +61,15 @@ 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.`);
try {
await runStdioAuth(definition);
await runStdioAuth(definition, { noBrowser });
logInfo(`Auth helper for '${target}' finished. You can now call tools.`);
} finally {
await persistPreparedEphemeralServer(runtime, prepared);
@ -65,10 +79,23 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
logInfo(`Initiating OAuth flow for '${target}'...`);
const tools = await runtime.listTools(target, { autoAuthorize: true });
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);
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
if (!noBrowser) {
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
}
return;
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
@ -78,12 +105,16 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
}
const message = error instanceof Error ? error.message : String(error);
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;
}
@ -92,16 +123,31 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
}
}
async function runStdioAuth(definition: ServerDefinition): Promise<void> {
async function withInfoLogsSuppressed<T>(enabled: boolean, task: () => Promise<T>): Promise<T> {
if (!enabled) {
return task();
}
const logger = getActiveLogger();
const originalInfo = logger.info.bind(logger);
logger.info = () => {};
try {
return await task();
} finally {
logger.info = originalInfo;
}
}
async function runStdioAuth(definition: ServerDefinition, options: { noBrowser?: boolean } = {}): Promise<void> {
const authArgs = [...(definition.command.kind === 'stdio' ? (definition.command.args ?? []) : [])];
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) => {
@ -114,6 +160,68 @@ async function runStdioAuth(definition: ServerDefinition): Promise<void> {
});
}
function buildNoBrowserOAuthOptions(
format: 'text' | 'json',
markAuthorizationOutputEmitted: () => void
): OAuthSessionOptions {
return {
suppressBrowserLaunch: true,
onAuthorizationUrl(request: OAuthAuthorizationRequest) {
markAuthorizationOutputEmitted();
if (format === 'json') {
console.log(
JSON.stringify(
{
authorizationUrl: request.authorizationUrl,
redirectUrl: request.redirectUrl,
},
null,
2
)
);
return;
}
console.log(request.authorizationUrl);
},
};
}
function consumeBrowserSuppression(args: string[], env: NodeJS.ProcessEnv): BrowserSuppression {
let mode = resolveBrowserSuppressionFromEnv(env.MCPORTER_OAUTH_NO_BROWSER);
const noBrowserIndex = args.indexOf('--no-browser');
if (noBrowserIndex !== -1) {
args.splice(noBrowserIndex, 1);
mode = 'no-browser';
}
const browserIndex = args.indexOf('--browser');
if (browserIndex !== -1) {
const value = args[browserIndex + 1];
if (!value) {
throw new Error("Flag '--browser' requires a value.");
}
if (value !== 'none') {
throw new Error("--browser must be 'none' when provided to mcporter auth.");
}
args.splice(browserIndex, 2);
mode = 'no-browser';
}
return mode;
}
function resolveBrowserSuppressionFromEnv(raw: string | undefined): BrowserSuppression {
if (raw === undefined) {
return 'default';
}
const normalized = raw.trim().toLowerCase();
if (!normalized || FALSE_VALUES.has(normalized)) {
return 'default';
}
if (TRUE_VALUES.has(normalized)) {
return 'no-browser';
}
return 'default';
}
function shouldRetryAuthError(error: unknown): boolean {
if (isOAuthFlowError(error)) {
return false;
@ -130,7 +238,10 @@ 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.',
@ -147,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',

View File

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

View File

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

View File

@ -1,5 +1,6 @@
export const CALL_HELP_ARGUMENT_LINES = [
' key=value / key:value Flag-style named arguments.',
' key=@path Read a UTF-8 string value from a file; use @@ for a literal @.',
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
' --args <json> Provide a JSON object payload.',
' positional values Accepted when schema order is known.',
@ -10,6 +11,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
' --timeout <ms> Override the call timeout.',
' --output text|markdown|json|raw Control formatting.',
' --save-images <dir> Save image content blocks to a directory.',
' --no-oauth Never start OAuth; use cached tokens only.',
' --raw-strings Keep numeric-looking argument values as strings.',
' --no-coerce Keep all key/value and positional arguments as raw strings.',
' --tail-log Stream returned log handles.',
@ -31,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
export const CALL_HELP_EXAMPLE_LINES = [
' mcporter call linear.list_issues team=ENG limit:5',
' mcporter call linear.create_comment body=@comment.md',
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',

View File

@ -2,6 +2,7 @@ import { resolveConfigPath } from '../config/path-discovery.js';
import { parseLogLevel } from '../logging.js';
import { extractFlags } from './flag-utils.js';
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
import { parsePositiveInteger } from './timeouts.js';
export interface GlobalCliContext {
readonly globalFlags: Record<string, string | undefined>;
@ -29,8 +30,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
let oauthTimeoutOverride: number | undefined;
if (globalFlags['--oauth-timeout']) {
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
if (parsed === undefined) {
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
return { exit: true, code: 1 };
}

View File

@ -84,7 +84,14 @@ function isCallLikeToken(token: string): boolean {
}
function isExplicitCommand(token: string): boolean {
return token === 'list' || token === 'call' || token === 'auth' || token === 'resource' || token === 'resources';
return (
token === 'list' ||
token === 'call' ||
token === 'auth' ||
token === 'vault' ||
token === 'resource' ||
token === 'resources'
);
}
function isUrlToken(token: string): boolean {

View File

@ -1,8 +1,8 @@
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';
@ -44,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 } : {};
@ -72,18 +69,23 @@ export async function handleAddCommand(options: ConfigCliOptions, args: string[]
throw new CliUsageError('Server definitions require either a --url/target or a stdio command.');
}
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}`);
}

View File

@ -98,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>',

View File

@ -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;

View File

@ -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}`);
}

View File

@ -14,6 +14,8 @@ export type SerializedServerDefinition = {
oauthTokenEndpointAuthMethod?: string;
oauthRedirectUrl?: string;
oauthScope?: string;
refresh?: ServerDefinition['refresh'];
httpFetch?: ServerDefinition['httpFetch'];
allowedTools?: readonly string[];
blockedTools?: readonly string[];
env?: Record<string, string>;
@ -40,6 +42,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
env: definition.env,
@ -60,6 +64,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
env: definition.env,
@ -91,8 +97,8 @@ 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>';

View File

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

View File

@ -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;

View File

@ -99,6 +99,8 @@ export function printGenerateCliHelp(): void {
' --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.',

View File

@ -1,7 +1,7 @@
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';
@ -16,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,
@ -70,20 +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,

View File

@ -5,6 +5,7 @@ import {
type HttpCommand,
loadServerDefinitions,
type RawLifecycle,
type RefreshableBearerOptions,
type ServerDefinition,
type ServerLoggingOptions,
type StdioCommand,
@ -205,6 +206,8 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
);
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);
@ -228,6 +231,8 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
oauthRedirectUrl,
oauthScope,
oauthCommand,
refresh,
httpFetch,
lifecycle: resolveLifecycle(name, rawLifecycle, command),
logging,
...(allowedTools !== undefined ? { allowedTools } : {}),
@ -382,6 +387,32 @@ function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | und
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];

View File

@ -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;

View File

@ -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}`);
}

View 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;
}

View File

@ -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();

View File

@ -9,6 +9,7 @@ import { markExecutable } from './fs-helpers.js';
import { renderEmbeddedHelpSource } from './template-help.js';
import type { GeneratedOption, ToolMetadata } from './tools.js';
import { buildEmbeddedSchemaMap } from './tools.js';
import { stableJsonStringify } from './stable-json.js';
export interface TemplateInput {
outputPath?: string;
@ -75,9 +76,12 @@ export function renderTemplate({
"import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';",
"import { createCallResult } from 'mcporter';",
].join('\n');
const embedded = JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value), 2);
const embedded = stableJsonStringify(
JSON.parse(JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value))),
2
);
const relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/steipete/mcporter`;
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/openclaw/mcporter`;
const toolDocs = tools.map((tool) => ({
tool,
doc: buildToolDoc({
@ -97,6 +101,7 @@ export function renderTemplate({
tool: entry.tool,
})
);
assertUniqueGeneratedCommandNames(renderedTools);
const toolHelp = renderedTools.map((entry) => ({
name: entry.commandName,
description: entry.tool.tool.description ?? '',
@ -104,15 +109,13 @@ export function renderTemplate({
flags: entry.doc.flagUsage ?? '',
}));
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
const toolHelpLiteral = JSON.stringify(toolHelp, undefined, 2);
const embeddedSchemas = JSON.stringify(buildEmbeddedSchemaMap(tools), undefined, 2);
const embeddedMetadata = JSON.stringify(metadata, undefined, 2);
const toolHelpLiteral = stableJsonStringify(toolHelp, 2);
const embeddedSchemas = stableJsonStringify(buildEmbeddedSchemaMap(tools), 2);
const embeddedMetadata = stableJsonStringify(metadata, 2);
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
const signatureMapLiteral = JSON.stringify(signatureMap, undefined, 2);
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version} on ${
metadata.generatedAt
}. DO NOT EDIT.`;
const signatureMapLiteral = stableJsonStringify(signatureMap, 2);
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version}. DO NOT EDIT.`;
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
${generatedHeaderComment}
${imports}
@ -235,7 +238,7 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
\t}
\tconst values = value.split(',').map((entry) => entry.trim());
\tif (itemType === 'number') {
\t\treturn values.map((entry) => parseFloat(entry));
\t\treturn values.map((entry) => parseFiniteNumber(entry));
\t}
\tif (itemType === 'boolean') {
\t\treturn values.map((entry) => entry !== 'false');
@ -243,6 +246,15 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
\treturn values;
}
function parseFiniteNumber(value: string): number {
\tconst trimmed = value.trim();
\tconst parsed = Number(trimmed);
\tif (trimmed === '' || !Number.isFinite(parsed)) {
\t\tthrow new Error('Expected a finite number.');
\t}
\treturn parsed;
}
function normalizeEmbeddedServer(server: typeof embeddedServer) {
\tconst base = { ...server } as Record<string, unknown>;
\tif ((server.command as any).kind === 'http') {
@ -281,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';
@ -460,7 +472,9 @@ export function renderToolCommand(
({ option, camelCaseProp }) =>
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
)
.join(', ')}].filter((entry) => entry.value === undefined).map((entry) => entry.flag);
.join(
', '
)}].filter((entry) => entry.value === undefined || (typeof entry.value === 'string' && entry.value.trim() === '')).map((entry) => entry.flag);
\t\t\tif (missingRequired.length > 0) {
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
\t\t\t}`
@ -547,7 +561,7 @@ export const templateTestHelpers = { computeRelativeStdioCwd };
function optionParser(option: GeneratedOption): string | undefined {
switch (option.type) {
case 'number':
return '(value) => parseFloat(value)';
return '(value) => parseFiniteNumber(value)';
case 'boolean':
return "(value) => value !== 'false'";
case 'object':
@ -568,3 +582,16 @@ function optionParser(option: GeneratedOption): string | undefined {
return undefined;
}
}
function assertUniqueGeneratedCommandNames(tools: Array<{ commandName: string; tool: ToolMetadata }>): void {
const commands = new Map<string, string>();
for (const entry of tools) {
const previous = commands.get(entry.commandName);
if (previous) {
throw new Error(
`Generated command name collision '${entry.commandName}' for tools '${previous}' and '${entry.tool.tool.name}'.`
);
}
commands.set(entry.commandName, entry.tool.tool.name);
}
}

View File

@ -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;
}

View File

@ -67,6 +67,21 @@ function buildCommandSections(colorize: boolean): string[] {
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>]',
},
],
},
{
@ -107,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>]',
},
],
},
];

View File

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

View File

@ -60,6 +60,9 @@ export async function handleList(
const perServerTimeoutSeconds = Math.round(perServerTimeoutMs / 1000);
if (servers.length === 0) {
if (flags.quiet) {
return;
}
if (flags.format === 'json') {
const payload = {
mode: 'list',
@ -73,17 +76,17 @@ export async function handleList(
return;
}
if (flags.format === 'text') {
if (!flags.quiet && flags.format === 'text') {
console.log(
`mcporter ${MCPORTER_VERSION} — Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`
);
}
const spinner =
flags.format === 'text' && supportsSpinner
!flags.quiet && flags.format === 'text' && supportsSpinner
? ora(`Discovering ${servers.length} server(s)…`).start()
: undefined;
const renderedResults =
flags.format === 'text'
!flags.quiet && flags.format === 'text'
? (Array.from({ length: servers.length }, () => undefined) as Array<
ReturnType<typeof renderServerListRow> | undefined
>)
@ -95,28 +98,7 @@ export async function handleList(
let completedCount = 0;
const tasks = servers.map((server, index) =>
(async (): Promise<ListSummaryResult> => {
const startedAt = Date.now();
try {
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
perServerTimeoutMs
);
return {
server,
status: 'ok' as const,
tools,
durationMs: Date.now() - startedAt,
};
} catch (error) {
return {
server,
status: 'error' as const,
error,
durationMs: Date.now() - startedAt,
};
}
})().then((result) => {
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
summaryResults[index] = result;
if (renderedResults) {
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
@ -139,20 +121,25 @@ export async function handleList(
);
await Promise.all(tasks);
const jsonEntries = summaryResults.map((entry, index) => {
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
if (!serverDefinition) {
throw new Error('Unable to resolve server definition for JSON output.');
}
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
includeSchemas: Boolean(flags.schema),
includeSources: Boolean(flags.verbose || flags.includeSources),
});
});
const counts = summarizeStatusCounts(jsonEntries);
maybeSetListExitCode(jsonEntries, flags);
if (flags.quiet) {
return;
}
if (flags.format === 'json') {
const jsonEntries = summaryResults.map((entry, index) => {
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
if (!serverDefinition) {
throw new Error('Unable to resolve server definition for JSON output.');
}
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
includeSchemas: Boolean(flags.schema),
includeSources: Boolean(flags.verbose || flags.includeSources),
});
});
const counts = summarizeStatusCounts(jsonEntries);
console.log(JSON.stringify({ mode: 'list', counts, servers: jsonEntries }, null, 2));
return;
}
@ -160,21 +147,13 @@ export async function handleList(
if (spinner) {
spinner.stop();
}
const errorCounts = createEmptyStatusCounts();
renderedResults?.forEach((entry) => {
if (!entry) {
return;
}
const category = entry.category ?? 'error';
errorCounts[category] = (errorCounts[category] ?? 0) + 1;
});
const okSummary = `${errorCounts.ok} healthy`;
const okSummary = `${counts.ok} healthy`;
const parts = [
okSummary,
...(errorCounts.auth > 0 ? [`${errorCounts.auth} auth required`] : []),
...(errorCounts.offline > 0 ? [`${errorCounts.offline} offline`] : []),
...(errorCounts.http > 0 ? [`${errorCounts.http} http errors`] : []),
...(errorCounts.error > 0 ? [`${errorCounts.error} errors`] : []),
...(counts.auth > 0 ? [`${counts.auth} auth required`] : []),
...(counts.offline > 0 ? [`${counts.offline} offline`] : []),
...(counts.http > 0 ? [`${counts.http} http errors`] : []),
...(counts.error > 0 ? [`${counts.error} errors`] : []),
];
console.log(`✔ Listed ${servers.length} server${servers.length === 1 ? '' : 's'} (${parts.join('; ')}).`);
return;
@ -190,9 +169,13 @@ export async function handleList(
requestedTool = selector.tool;
}
}
if (flags.statusOnly && requestedTool) {
throw new Error('--status cannot be used with a tool selector.');
}
const resolved = resolveServerDefinition(runtime, target);
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
if (!resolved) {
process.exitCode = 1;
return;
}
target = resolved.name;
@ -204,14 +187,119 @@ export async function handleList(
: undefined;
const transportSummary = formatTransportSummary(definition);
const startedAt = Date.now();
if (flags.format === 'json') {
if (flags.statusOnly) {
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
try {
const result = await checkListServer(runtime, definition, timeoutMs, flags.disableOAuth);
await persistPreparedEphemeralServer(runtime, prepared);
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
includeSchemas: false,
includeSources: Boolean(flags.verbose || flags.includeSources),
});
maybeSetListExitCode([entry], flags);
if (flags.quiet) {
return;
}
if (flags.format === 'json') {
console.log(
JSON.stringify({ mode: 'list', counts: summarizeStatusCounts([entry]), servers: [entry] }, null, 2)
);
return;
}
const rendered = renderServerListRow(result, timeoutMs, { verbose: flags.verbose });
console.log(rendered.line);
console.log(
`✔ Listed 1 server (${entry.status === 'ok' ? '1 healthy' : `0 healthy; 1 ${statusLabel(entry.status)}`}).`
);
return;
} finally {
if (previousStdioLogMode !== undefined) {
setStdioLogMode(previousStdioLogMode);
}
}
}
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
try {
if (flags.format === 'json') {
try {
const metadataEntries = filterToolMetadata(
await withTimeout(
loadToolMetadata(runtime, target, {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: flags.disableOAuth,
}),
timeoutMs
),
requestedTool
);
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
if (requestedTool && metadataEntries.length === 0) {
if (!flags.quiet) {
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
}
process.exitCode = 1;
return;
}
const instructions = await loadServerInstructions(runtime, target);
const payload = {
mode: 'server',
name: definition.name,
status: 'ok' as StatusCategory,
durationMs,
description: definition.description,
instructions,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
tools: metadataEntries.map((entry) => ({
name: entry.tool.name,
description: entry.tool.description,
inputSchema: entry.tool.inputSchema,
outputSchema: entry.tool.outputSchema,
options: entry.options,
})),
};
if (!flags.quiet) {
console.log(JSON.stringify(payload, null, 2));
}
return;
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const payload = {
mode: 'server',
name: definition.name,
status: advice.category,
durationMs,
description: definition.description,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
issue: advice.issue,
authCommand: advice.authCommand,
error: advice.summary,
};
if (!flags.quiet) {
console.log(JSON.stringify(payload, null, 2));
}
process.exitCode = 1;
return;
}
}
try {
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
const metadataEntries = filterToolMetadata(
await withTimeout(
loadToolMetadata(runtime, target, {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: flags.disableOAuth,
}),
timeoutMs
),
@ -220,96 +308,62 @@ export async function handleList(
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
if (requestedTool && metadataEntries.length === 0) {
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
if (!flags.quiet) {
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
}
process.exitCode = 1;
return;
}
if (flags.quiet) {
return;
}
const instructions = await loadServerInstructions(runtime, target);
const payload = {
mode: 'server',
name: definition.name,
status: 'ok' as StatusCategory,
const summaryLine = printSingleServerHeader(
definition,
metadataEntries.length,
durationMs,
description: definition.description,
instructions,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
tools: metadataEntries.map((entry) => ({
name: entry.tool.name,
description: entry.tool.description,
inputSchema: entry.tool.inputSchema,
outputSchema: entry.tool.outputSchema,
options: entry.options,
})),
};
console.log(JSON.stringify(payload, null, 2));
return;
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const payload = {
mode: 'server',
name: definition.name,
status: advice.category,
durationMs,
description: definition.description,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
issue: advice.issue,
authCommand: advice.authCommand,
error: advice.summary,
};
console.log(JSON.stringify(payload, null, 2));
process.exitCode = 1;
return;
}
}
try {
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
const metadataEntries = filterToolMetadata(
await withTimeout(
loadToolMetadata(runtime, target, {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
}),
timeoutMs
),
requestedTool
);
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
if (requestedTool && metadataEntries.length === 0) {
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
return;
}
const instructions = await loadServerInstructions(runtime, target);
const summaryLine = printSingleServerHeader(
definition,
metadataEntries.length,
durationMs,
transportSummary,
sourcePath,
{
printSummaryNow: false,
instructions,
transportSummary,
sourcePath,
{
printSummaryNow: false,
instructions,
}
);
if (metadataEntries.length === 0) {
console.log(' Tools: <none>');
console.log(summaryLine);
console.log('');
return;
}
);
if (metadataEntries.length === 0) {
console.log(' Tools: <none>');
console.log(summaryLine);
console.log('');
return;
}
if (flags.brief) {
if (flags.brief) {
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printBriefTool(definition, entry, flags.requiredOnly);
optionalOmitted ||= detail.optionalOmitted;
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
}
console.log(summaryLine);
console.log('');
return;
}
const examples: string[] = [];
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printBriefTool(definition, entry, flags.requiredOnly);
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
examples.push(...detail.examples);
optionalOmitted ||= detail.optionalOmitted;
}
const uniqueExamples = formatExampleBlock(examples);
if (uniqueExamples.length > 0) {
console.log(` ${dimText('Examples:')}`);
for (const example of uniqueExamples) {
console.log(` ${example}`);
}
console.log('');
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
@ -317,42 +371,85 @@ export async function handleList(
console.log(summaryLine);
console.log('');
return;
}
const examples: string[] = [];
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
examples.push(...detail.examples);
optionalOmitted ||= detail.optionalOmitted;
}
const uniqueExamples = formatExampleBlock(examples);
if (uniqueExamples.length > 0) {
console.log(` ${dimText('Examples:')}`);
for (const example of uniqueExamples) {
console.log(` ${example}`);
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
maybeSetListExitCode([{ status: 'error' }], flags);
if (flags.quiet) {
return;
}
const durationMs = Date.now() - startedAt;
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
console.warn(` Reason: ${message}`);
if (advice.category === 'auth' && advice.authCommand) {
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
}
console.log('');
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
} finally {
if (previousStdioLogMode !== undefined) {
setStdioLogMode(previousStdioLogMode);
}
console.log(summaryLine);
console.log('');
return;
}
}
async function checkListServer(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: ServerDefinition,
timeoutMs: number,
disableOAuth: boolean
): Promise<ListSummaryResult> {
const startedAt = Date.now();
try {
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true, disableOAuth }),
timeoutMs
);
return {
server,
status: 'ok' as const,
tools,
durationMs: Date.now() - startedAt,
};
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
console.warn(` Reason: ${message}`);
if (advice.category === 'auth' && advice.authCommand) {
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
}
return {
server,
status: 'error' as const,
error,
durationMs: Date.now() - startedAt,
};
}
}
function maybeSetListExitCode(
entries: readonly { status: StatusCategory }[],
flags: ReturnType<typeof extractListFlags>
): void {
if (!flags.exitCode) {
return;
}
if (entries.some((entry) => entry.status !== 'ok')) {
process.exitCode = 1;
}
}
function statusLabel(status: StatusCategory): string {
switch (status) {
case 'auth':
return 'auth required';
case 'offline':
return 'offline';
case 'http':
return 'http error';
case 'error':
return 'error';
case 'ok':
return 'healthy';
default:
return 'error';
}
}
@ -383,13 +480,19 @@ export function printListHelp(): void {
' --schema Show tool schemas when listing servers.',
' --all-parameters Include optional parameters in tool docs.',
' --json Emit a JSON summary instead of text.',
' --status Check server status only, without tool docs.',
' --exit-code Exit 1 when any checked server is unhealthy.',
' --quiet Suppress output; implies --exit-code.',
' --verbose Show all config sources for matching servers.',
' --sources Include source arrays in JSON output without other verbose details.',
' --timeout <ms> Override the per-server discovery timeout.',
' --no-oauth Never start OAuth; use cached tokens only.',
'',
'Examples:',
' mcporter list',
' mcporter list --quiet',
' mcporter list linear --schema',
' mcporter list linear --status --json',
' mcporter list linear --brief',
' mcporter list linear.list_issues --signatures',
' mcporter list https://mcp.example.com/mcp',
@ -400,7 +503,8 @@ export function printListHelp(): void {
function resolveServerDefinition(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
name: string
name: string,
options: { quiet?: boolean } = {}
): { definition: ServerDefinition; name: string } | undefined {
try {
const definition = runtime.getDefinition(name);
@ -411,7 +515,9 @@ function resolveServerDefinition(
}
const suggestion = suggestServerName(runtime, name);
if (!suggestion) {
console.error(error.message);
if (!options.quiet) {
console.error(error.message);
}
return undefined;
}
const messages = renderIdentifierResolutionMessages({
@ -420,13 +526,17 @@ function resolveServerDefinition(
resolution: suggestion,
});
if (suggestion.kind === 'auto' && messages.auto) {
console.log(dimText(messages.auto));
return resolveServerDefinition(runtime, suggestion.value);
if (!options.quiet) {
console.log(dimText(messages.auto));
}
return resolveServerDefinition(runtime, suggestion.value, options);
}
if (messages.suggest) {
if (!options.quiet && messages.suggest) {
console.error(yellowText(messages.suggest));
}
console.error(error.message);
if (!options.quiet) {
console.error(error.message);
}
return undefined;
}
}

View File

@ -14,6 +14,10 @@ export function extractListFlags(args: string[]): {
verbose: boolean;
includeSources: boolean;
brief: boolean;
quiet: boolean;
exitCode: boolean;
statusOnly: boolean;
disableOAuth: boolean;
} {
let schema = false;
let timeoutMs: number | undefined;
@ -21,6 +25,10 @@ export function extractListFlags(args: string[]): {
let verbose = false;
let includeSources = false;
let brief = false;
let quiet = false;
let exitCode = false;
let statusOnly = false;
let disableOAuth = false;
const format = consumeOutputFormat(args, {
defaultFormat: 'text',
allowed: ['text', 'json'],
@ -60,6 +68,27 @@ export function extractListFlags(args: string[]): {
args.splice(index, 1);
continue;
}
if (token === '--quiet') {
quiet = true;
exitCode = true;
args.splice(index, 1);
continue;
}
if (token === '--exit-code') {
exitCode = true;
args.splice(index, 1);
continue;
}
if (token === '--status') {
statusOnly = true;
args.splice(index, 1);
continue;
}
if (token === '--no-oauth') {
disableOAuth = true;
args.splice(index, 1);
continue;
}
if (token === '--timeout') {
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
continue;
@ -84,5 +113,33 @@ export function extractListFlags(args: string[]): {
throw new Error(`--brief cannot be used with ${conflicts.join(', ')}`);
}
}
return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose, includeSources, brief };
if (statusOnly) {
const conflicts: string[] = [];
if (brief) {
conflicts.push('--brief');
}
if (schema) {
conflicts.push('--schema');
}
if (!requiredOnly) {
conflicts.push('--all-parameters');
}
if (conflicts.length > 0) {
throw new Error(`--status cannot be used with ${conflicts.join(', ')}`);
}
}
return {
schema,
timeoutMs,
requiredOnly,
ephemeral,
format,
verbose,
includeSources,
brief,
quiet,
exitCode,
statusOnly,
disableOAuth,
};
}

View File

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

View File

@ -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
View 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 };
}

View 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
View 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();
});
});
}

View File

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

197
src/cli/serve-command.ts Normal file
View 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;
}

View File

@ -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);

View File

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

175
src/cli/vault-command.ts Normal file
View 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'));
}

View File

@ -1,7 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
import { expandHome } from './env.js';
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(
@ -11,6 +19,8 @@ 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);
@ -23,6 +33,8 @@ export function normalizeServerEntry(
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);
@ -74,6 +86,8 @@ export function normalizeServerEntry(
oauthRedirectUrl,
oauthScope,
oauthCommand: defaultedOauthCommand,
refresh,
httpFetch,
source,
sources,
lifecycle,
@ -85,8 +99,55 @@ export function normalizeServerEntry(
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;
@ -94,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;

View File

@ -48,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'),
@ -71,11 +102,11 @@ export const RawEntrySchema = z
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)'),
@ -118,6 +149,9 @@ 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)'),
@ -141,6 +175,18 @@ export const RawEntrySchema = z
export const RawConfigSchema = z
.object({
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
daemonIdleTimeoutMs: z
.number()
.int()
.positive()
.optional()
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
daemon_idle_timeout_ms: z
.number()
.int()
.positive()
.optional()
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
imports: z
.array(ImportKindSchema)
.optional()
@ -150,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';
@ -187,6 +234,15 @@ export interface ServerLoggingOptions {
};
}
export interface RefreshableBearerOptions {
readonly tokenEndpoint: string;
readonly clientIdEnv?: string;
readonly clientSecretEnv?: string;
readonly clientAuthMethod?: string;
readonly refreshSkewSeconds?: number;
readonly accessTokenEnv?: string;
}
export interface ServerDefinition {
readonly name: string;
readonly description?: string;
@ -204,6 +260,8 @@ export interface ServerDefinition {
readonly oauthCommand?: {
readonly args: string[];
};
readonly refresh?: RefreshableBearerOptions;
readonly httpFetch?: 'default' | 'node-http1';
readonly source?: ServerSource;
readonly sources?: readonly ServerSource[];
readonly lifecycle?: ServerLifecycle;

View File

@ -1,4 +1,3 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {
listConfigLayerPaths as discoverConfigLayerPaths,
@ -17,6 +16,7 @@ import {
type ServerSource,
} from './config-schema.js';
import { expandHome } from './env.js';
import { writeTextFileAtomic } from './fs-json.js';
export { toFileUrl } from './config-imports.js';
export { __configInternals } from './config-normalize.js';
@ -27,6 +27,7 @@ export type {
RawConfig,
RawEntry,
RawLifecycle,
RefreshableBearerOptions,
ServerDefinition,
ServerLifecycle,
ServerLoggingOptions,
@ -57,10 +58,16 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
continue;
}
for (const [name, rawEntry] of entries) {
const source: ServerSource = { kind: 'import', path: resolved, importKind };
const baseDir = path.dirname(resolved);
try {
normalizeServerEntry(name, rawEntry, baseDir, source, [source]);
} catch {
continue;
}
if (merged.has(name)) {
continue;
}
const source: ServerSource = { kind: 'import', path: resolved, importKind };
const existing = merged.get(name);
// Keep the first-seen source as canonical while tracking all alternates
if (existing) {
@ -69,7 +76,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
}
merged.set(name, {
raw: rawEntry,
baseDir: path.dirname(resolved),
baseDir,
source,
sources: [source],
});
@ -98,12 +105,35 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
const servers: ServerDefinition[] = [];
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
try {
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
} catch (error) {
if (source.kind !== 'import') {
throw error;
}
}
}
return servers;
}
export interface DaemonConfig {
readonly idleTimeoutMs?: number;
}
export async function loadDaemonConfig(options: LoadConfigOptions = {}): Promise<DaemonConfig> {
const rootDir = options.rootDir ?? process.cwd();
const layers = await loadConfigLayers(options, rootDir);
let idleTimeoutMs: number | undefined;
for (const layer of layers) {
const raw = layer.config.daemonIdleTimeoutMs ?? layer.config.daemon_idle_timeout_ms;
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
idleTimeoutMs = Math.trunc(raw);
}
}
return { idleTimeoutMs };
}
export async function loadRawConfig(
options: LoadConfigOptions = {}
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
@ -121,9 +151,8 @@ export async function listConfigLayerPaths(
}
export async function writeRawConfig(targetPath: string, config: RawConfig): Promise<void> {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
const serialized = `${JSON.stringify(config, null, 2)}\n`;
await fs.writeFile(targetPath, serialized, 'utf8');
await writeTextFileAtomic(targetPath, serialized);
}
export function resolveConfigPath(

View File

@ -153,6 +153,16 @@ function convertExternalEntry(value: Record<string, unknown>): RawEntry | null {
result.oauthTokenEndpointAuthMethod = oauthTokenEndpointAuthMethod;
}
const httpFetch = asString(value.httpFetch ?? value.http_fetch);
if (httpFetch) {
result.httpFetch = httpFetch;
}
const refresh = asRefresh(value.refresh);
if (refresh) {
result.refresh = refresh;
}
const url = asString(value.baseUrl ?? value.base_url ?? value.url ?? value.serverUrl ?? value.server_url);
if (url) {
result.baseUrl = url;
@ -201,6 +211,36 @@ function buildExternalHeaders(record: Record<string, unknown>): Record<string, s
return Object.keys(headers).length > 0 ? headers : undefined;
}
function asRefresh(value: unknown): RawEntry['refresh'] | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
copyString(record, result, 'tokenEndpoint', 'token_endpoint');
copyString(record, result, 'clientIdEnv', 'client_id_env');
copyString(record, result, 'clientSecretEnv', 'client_secret_env');
copyString(record, result, 'clientAuthMethod', 'client_auth_method');
copyString(record, result, 'accessTokenEnv', 'access_token_env');
const refreshSkewSeconds = record.refreshSkewSeconds ?? record.refresh_skew_seconds;
if (typeof refreshSkewSeconds === 'number' && Number.isInteger(refreshSkewSeconds) && refreshSkewSeconds >= 0) {
result.refreshSkewSeconds = refreshSkewSeconds;
}
return Object.keys(result).length > 0 ? (result as RawEntry['refresh']) : undefined;
}
function copyString(
source: Record<string, unknown>,
target: Record<string, unknown>,
camel: string,
snake: string
): void {
const value = asString(source[camel] ?? source[snake]);
if (value) {
target[camel] = value;
}
}
function extractClaudeProjectEntries(raw: Record<string, unknown>, projectRoot: string): Map<string, RawEntry> {
const map = new Map<string, RawEntry>();
if (!isRecord(raw.projects)) {

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import type { ServerDefinition } from '../config.js';
import { loadDaemonConfig, type ServerDefinition } from '../config.js';
import { readJsonFile, withFileLock, writeJsonFile } from '../fs-json.js';
import { isKeepAliveServer } from '../lifecycle.js';
import { createRuntime, type Runtime } from '../runtime.js';
import { collectConfigLayers, statConfigMtime } from './config-layers.js';
import { hashDaemonDefinitions } from './definition-hash.js';
import {
createLogContext,
disposeLogContext,
@ -25,9 +28,11 @@ import type {
} from './protocol.js';
import {
buildErrorResponse,
daemonIdleWatcherInterval,
ensureManaged,
evictIdleServers,
markActivity,
shouldShutdownDaemonForIdle,
type ServerActivity,
} from './request-utils.js';
@ -47,11 +52,16 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
configPath: options.configExplicit ? options.configPath : undefined,
rootDir: options.rootDir,
});
const daemonConfig = await loadDaemonConfig({
configPath: options.configExplicit ? options.configPath : undefined,
rootDir: options.rootDir,
});
const runtime = await createRuntime({
configPath: options.configExplicit ? options.configPath : undefined,
rootDir: options.rootDir,
});
const keepAliveDefinitions = runtime.getDefinitions().filter(isKeepAliveServer);
const definitionHash = hashDaemonDefinitions(keepAliveDefinitions);
if (keepAliveDefinitions.length === 0) {
throw new Error('No MCP servers require keep-alive; daemon will not start.');
}
@ -76,7 +86,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
logPath: options.logPath,
});
await prepareSocket(options.socketPath);
await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
const configMtimeMs = await statConfigMtime(options.configPath);
@ -85,9 +94,37 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
activity.set(definition.name, { connected: false });
}
const idleWatcher = setInterval(() => {
void evictIdleServers(runtime, managedServers, activity);
}, 30_000);
let shuttingDown = false;
let idleWatcher: NodeJS.Timeout | undefined;
const shutdown = async (): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
logEvent(logContext, 'Shutting down daemon host.');
if (idleWatcher) {
clearInterval(idleWatcher);
}
server.close();
await runtime.close().catch(() => {});
await disposeLogContext(logContext).catch(() => {});
await cleanupArtifacts(options);
process.exit(0);
};
let lastDaemonActivityAt = Date.now();
let activeDaemonRequests = 0;
idleWatcher = setInterval(() => {
void (async () => {
await evictIdleServers(runtime, managedServers, activity);
if (
shouldShutdownDaemonForIdle(lastDaemonActivityAt, Date.now(), daemonConfig.idleTimeoutMs, activeDaemonRequests)
) {
logEvent(logContext, 'Daemon idle timeout reached.');
await shutdown();
}
})();
}, daemonIdleWatcherInterval(daemonConfig.idleTimeoutMs));
idleWatcher.unref();
logEvent(logContext, 'Daemon host started.');
@ -114,6 +151,8 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
return;
}
handled = true;
lastDaemonActivityAt = Date.now();
activeDaemonRequests += 1;
void handleSocketRequest(
trimmed,
socket,
@ -127,11 +166,15 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
startedAt,
logPath: options.logPath ?? null,
configMtimeMs,
definitionHash,
},
logContext,
shutdown,
parsedRequest
);
).finally(() => {
activeDaemonRequests -= 1;
lastDaemonActivityAt = Date.now();
});
};
socket.on('data', (chunk) => {
buffer += chunk;
@ -148,52 +191,252 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
});
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options.socketPath, () => {
server.off('error', reject);
resolve();
let claimed = false;
await withFileLock(`${options.metadataPath}.bind`, async () => {
const live = await probeLiveDaemon(options.socketPath);
if (live) {
if (daemonConfigMatches(live, configLayers, options.configPath, configMtimeMs, definitionHash)) {
if (!(await metadataMatches(options.metadataPath, live))) {
await writeJsonFile(options.metadataPath, metadataFromStatus(live, configLayers));
}
return;
}
await stopLiveDaemon(options.socketPath, live.pid);
}
await prepareSocket(options.socketPath);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options.socketPath, () => {
server.off('error', reject);
resolve();
});
});
await writeJsonFile(options.metadataPath, {
pid: process.pid,
socketPath: options.socketPath,
configPath: options.configPath,
configLayers,
startedAt: Date.now(),
logPath: options.logPath ?? null,
configMtimeMs,
definitionHash,
});
claimed = true;
});
await fs.writeFile(
options.metadataPath,
JSON.stringify(
{
pid: process.pid,
socketPath: options.socketPath,
configPath: options.configPath,
configLayers,
startedAt: Date.now(),
logPath: options.logPath ?? null,
configMtimeMs,
},
null,
2
),
'utf8'
);
let shuttingDown = false;
const shutdown = async (): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
logEvent(logContext, 'Shutting down daemon host.');
clearInterval(idleWatcher);
if (!claimed) {
logEvent(logContext, 'Daemon already running for this config; exiting without rebinding.');
server.close();
await runtime.close().catch(() => {});
await disposeLogContext(logContext).catch(() => {});
await cleanupArtifacts(options);
process.exit(0);
};
}
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
process.once('SIGQUIT', shutdown);
}
const DAEMON_PROBE_TIMEOUT_MS = 2_000;
export async function isDaemonResponding(socketPath: string): Promise<boolean> {
return (await probeLiveDaemon(socketPath)) !== null;
}
async function probeLiveDaemon(socketPath: string): Promise<StatusResult | null> {
const status = await probeDaemonStatus(socketPath);
if (!status || status.socketPath !== socketPath || !isProcessAlive(status.pid)) {
return null;
}
return status;
}
export async function metadataMatches(
metadataPath: string,
live: Pick<StatusResult, 'pid' | 'socketPath'>
): Promise<boolean> {
try {
const existing = await readJsonFile<{ pid?: number; socketPath?: string }>(metadataPath);
return existing?.pid === live.pid && existing?.socketPath === live.socketPath;
} catch {
return false;
}
}
function metadataFromStatus(
status: StatusResult,
fallbackConfigLayers: Array<{ path: string; mtimeMs: number | null }>
): {
pid: number;
socketPath: string;
configPath: string;
configLayers?: StatusResult['configLayers'];
startedAt: number;
logPath: string | null;
configMtimeMs: number | null;
definitionHash?: string;
} {
return {
pid: status.pid,
socketPath: status.socketPath,
configPath: status.configPath,
configLayers: status.configLayers && status.configLayers.length > 0 ? status.configLayers : fallbackConfigLayers,
startedAt: status.startedAt,
logPath: status.logPath ?? null,
configMtimeMs: status.configMtimeMs ?? null,
definitionHash: status.definitionHash,
};
}
function daemonConfigMatches(
live: StatusResult,
currentLayers: Array<{ path: string; mtimeMs: number | null }>,
currentConfigPath: string,
currentConfigMtimeMs: number | null,
currentDefinitionHash: string
): boolean {
if (live.definitionHash !== currentDefinitionHash) {
return false;
}
const liveLayers = normalizeLayers(
live.configLayers && live.configLayers.length > 0
? live.configLayers
: [{ path: live.configPath, mtimeMs: live.configMtimeMs ?? null }]
);
const expectedLayers = normalizeLayers(
currentLayers.length > 0 ? currentLayers : [{ path: currentConfigPath, mtimeMs: currentConfigMtimeMs }]
);
if (liveLayers.length !== expectedLayers.length) {
return false;
}
return liveLayers.every((entry, index) => {
const expected = expectedLayers[index];
return Boolean(expected && entry.path === expected.path && entry.mtimeMs === expected.mtimeMs);
});
}
function normalizeLayers(
layers: Array<{ path: string; mtimeMs: number | null }>
): Array<{ path: string; mtimeMs: number | null }> {
const normalized = layers.map((entry) => ({
path: path.isAbsolute(entry.path) ? entry.path : path.resolve(entry.path),
mtimeMs: entry.mtimeMs ?? null,
}));
if (normalized.length < 2) {
return normalized;
}
return normalized.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}
async function stopLiveDaemon(socketPath: string, livePid: number): Promise<void> {
const stopped = await sendDaemonStop(socketPath);
if (!stopped) {
throw new Error('Live daemon did not accept stop before rebinding.');
}
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
if (!isProcessAlive(livePid)) {
return;
}
await delay(100);
}
throw new Error('Live daemon did not stop before rebinding.');
}
async function sendDaemonStop(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const request: DaemonRequest<'stop', Record<string, never>> = {
id: randomUUID(),
method: 'stop',
params: {},
};
const socket = net.createConnection(socketPath);
let buffer = '';
let settled = false;
const finish = (result: boolean): void => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.setTimeout(DAEMON_PROBE_TIMEOUT_MS, () => finish(false));
socket.once('connect', () => {
socket.write(JSON.stringify(request));
});
socket.on('data', (chunk) => {
buffer += chunk.toString();
});
socket.once('end', () => {
try {
const response = JSON.parse(buffer.trim()) as DaemonResponse<boolean>;
finish(response.ok);
} catch {
finish(false);
}
});
socket.once('error', () => finish(false));
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
async function probeDaemonStatus(socketPath: string): Promise<StatusResult | null> {
return await new Promise<StatusResult | null>((resolve) => {
const probe = net.createConnection(socketPath);
let buffer = '';
let settled = false;
const finish = (status: StatusResult | null): void => {
if (settled) {
return;
}
settled = true;
probe.removeAllListeners();
probe.destroy();
resolve(status);
};
const parse = (): StatusResult | null => {
try {
const response = JSON.parse(buffer.trim()) as DaemonResponse<StatusResult>;
return response.ok && response.result ? response.result : null;
} catch {
return null;
}
};
probe.setTimeout(DAEMON_PROBE_TIMEOUT_MS, () => finish(null));
probe.once('connect', () => {
probe.write(JSON.stringify({ id: randomUUID(), method: 'status', params: {} } satisfies DaemonRequest));
});
probe.on('data', (chunk) => {
buffer += chunk.toString();
const status = parse();
if (status) {
finish(status);
}
});
probe.once('end', () => finish(parse()));
probe.once('error', () => finish(null));
});
}
async function prepareSocket(socketPath: string): Promise<void> {
if (process.platform === 'win32') {
return;
@ -209,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
}
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
await cleanupDaemonArtifactsIfOwned(options, process.pid);
}
export async function cleanupDaemonArtifactsIfOwned(
paths: Pick<DaemonHostOptions, 'metadataPath' | 'socketPath'>,
ownerPid: number
): Promise<void> {
// A superseded daemon may finish shutting down after its replacement has
// already rebound the same paths. Never let that old process unlink the
// replacement daemon's live socket and metadata.
const metadata = await readJsonFile<{ pid?: number; socketPath?: string }>(paths.metadataPath).catch(() => undefined);
if (metadata?.pid !== ownerPid || metadata.socketPath !== paths.socketPath) {
return;
}
if (process.platform !== 'win32') {
try {
await fs.unlink(options.socketPath);
} catch {
// ignore
}
}
try {
await fs.unlink(options.metadataPath);
} catch {
// ignore
await fs.unlink(paths.socketPath).catch(() => {});
}
await fs.unlink(paths.metadataPath).catch(() => {});
}
async function handleSocketRequest(
@ -236,6 +485,7 @@ async function handleSocketRequest(
socketPath: string;
startedAt: number;
logPath: string | null;
definitionHash?: string;
},
logContext: LogContext,
shutdown: () => Promise<void>,
@ -259,6 +509,13 @@ async function handleSocketRequest(
});
}
function normalizeDaemonDisableOAuth(value: boolean | undefined): boolean {
// Daemon messages are independent requests. Omission means the caller did
// not request OAuth suppression, so a previous --no-oauth pooled transport
// must not make later ordinary calls inherit the no-OAuth posture.
return value === true;
}
async function processRequest(
rawPayload: string,
runtime: Runtime,
@ -271,6 +528,7 @@ async function processRequest(
socketPath: string;
startedAt: number;
logPath: string | null;
definitionHash?: string;
},
logContext: LogContext,
preParsedRequest?: DaemonRequest
@ -309,6 +567,7 @@ async function processRequest(
const result = await runtime.callTool(params.server, params.tool, {
args: params.args ?? {},
timeoutMs: params.timeoutMs,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
@ -326,6 +585,7 @@ async function processRequest(
case 'listTools': {
const params = request.params as ListToolsParams;
ensureManaged(params.server, managedServers);
const definition = managedServers.get(params.server)!;
const loggable = shouldLogServer(logContext, params.server);
if (loggable) {
logEvent(logContext, `listTools start server=${params.server}`);
@ -333,7 +593,9 @@ async function processRequest(
try {
const result = await runtime.listTools(params.server, {
includeSchema: params.includeSchema,
autoAuthorize: params.autoAuthorize,
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
allowCachedAuth: params.allowCachedAuth ?? true,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
@ -356,7 +618,11 @@ async function processRequest(
logEvent(logContext, `listResources start server=${params.server}`);
}
try {
const result = await runtime.listResources(params.server, params.params);
const result = await runtime.listResources(params.server, {
...params.params,
allowCachedAuth: params.allowCachedAuth,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
logEvent(logContext, `listResources success server=${params.server}`);
@ -378,7 +644,10 @@ async function processRequest(
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
}
try {
const result = await runtime.readResource(params.server, params.uri);
const result = await runtime.readResource(params.server, params.uri, {
allowCachedAuth: params.allowCachedAuth,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
logEvent(logContext, `readResource success server=${params.server}`);
@ -424,6 +693,7 @@ async function processRequest(
configPath: metadata.configPath,
configLayers: metadata.configLayers,
configMtimeMs: metadata.configMtimeMs,
definitionHash: metadata.definitionHash,
socketPath: metadata.socketPath,
logPath: metadata.logPath ?? undefined,
servers: Array.from(managedServers.values()).map((def) => {
@ -458,6 +728,16 @@ async function processRequest(
}
}
function resolveDaemonListToolsAutoAuthorize(
params: ListToolsParams,
definition: ServerDefinition
): boolean | undefined {
if (params.autoAuthorize === false && definition.command.kind === 'stdio') {
return undefined;
}
return params.autoAuthorize;
}
export async function __testProcessRequest(
rawPayload: string,
runtime: Runtime,
@ -470,6 +750,7 @@ export async function __testProcessRequest(
socketPath: string;
startedAt: number;
logPath: string | null;
definitionHash?: string;
},
logContext: LogContext,
preParsedRequest?: DaemonRequest

View File

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

View File

@ -49,6 +49,27 @@ export async function evictIdleServers(
);
}
export function shouldShutdownDaemonForIdle(
lastActivityAt: number,
now: number,
idleTimeoutMs: number | undefined,
activeRequests = 0
): boolean {
return (
activeRequests <= 0 &&
typeof idleTimeoutMs === 'number' &&
idleTimeoutMs > 0 &&
now - lastActivityAt >= idleTimeoutMs
);
}
export function daemonIdleWatcherInterval(idleTimeoutMs: number | undefined): number {
if (!idleTimeoutMs) {
return 30_000;
}
return Math.min(30_000, Math.max(100, Math.floor(idleTimeoutMs / 2)));
}
export function buildErrorResponse(id: string, code: string, error?: unknown): DaemonResponse {
let message = code;
if (error instanceof Error) {

View File

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

View File

@ -2,7 +2,7 @@ import os from 'node:os';
import path from 'node:path';
const ENV_DEFAULT_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-|:|-)?([^}]*)\}$/;
const ENV_INTERPOLATION_PATTERN = /\\?\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
const ENV_INTERPOLATION_PATTERN = /\\?\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}/g;
const ENV_DIRECT_PREFIX = '$env:';
// expandHome replaces a leading '~' with the current user's home directory.
@ -59,8 +59,14 @@ export function resolveEnvPlaceholders(value: string): string {
}
const missing = new Set<string>();
const replaced = value.replace(ENV_INTERPOLATION_PATTERN, (placeholder, envName: string) => {
const replaced = value.replace(ENV_INTERPOLATION_PATTERN, (placeholder, envName: string, fallback?: string) => {
const envValue = process.env[envName];
if (envValue !== undefined && envValue !== '') {
return envValue;
}
if (fallback !== undefined) {
return fallback;
}
if (envValue === undefined) {
missing.add(envName);
return placeholder;

View File

@ -1,6 +1,15 @@
import crypto from 'node:crypto';
import { constants } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
const LOCK_POLL_MS = 25;
const MALFORMED_LOCK_STALE_MS = 1_000;
const MAX_SYMLINK_DEPTH = 40;
const DEFAULT_ATOMIC_FILE_MODE = 0o600;
const localLockTails = new Map<string, Promise<void>>();
// readJsonFile reads a JSON file and returns undefined when the file does not exist.
export async function readJsonFile<T = unknown>(filePath: string): Promise<T | undefined> {
try {
@ -14,8 +23,268 @@ export async function readJsonFile<T = unknown>(filePath: string): Promise<T | u
}
}
// writeTextFileAtomic writes a file via same-directory temp file and rename.
export async function writeTextFileAtomic(filePath: string, data: string): Promise<void> {
const target = await resolveAtomicWriteTarget(filePath);
await fs.mkdir(path.dirname(target.path), { recursive: true });
const tempPath = path.join(
path.dirname(target.path),
`.${path.basename(target.path)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
);
try {
if (target.mode !== undefined) {
await fs.access(target.path, constants.W_OK);
}
await fs.writeFile(tempPath, data, {
encoding: 'utf8',
flag: 'wx',
mode: target.mode ?? DEFAULT_ATOMIC_FILE_MODE,
});
if (target.mode !== undefined) {
await fs.chmod(tempPath, target.mode);
}
await fs.rename(tempPath, target.path);
} catch (error) {
await fs.unlink(tempPath).catch(() => {});
if (target.mode !== undefined && isPermissionError(error)) {
await fs.writeFile(filePath, data, 'utf8');
return;
}
throw error;
}
}
// writeJsonFile writes a JSON object to disk, ensuring parent directories are created first.
export async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
await writeTextFileAtomic(filePath, JSON.stringify(data, null, 2));
}
export async function withFileLock<T>(
filePath: string,
task: () => Promise<T>,
options: { timeoutMs?: number } = {}
): Promise<T> {
const lockTargetPath = await resolvePathFollowingSymlinks(filePath);
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
const startedAt = Date.now();
return withLocalLock(lockTargetPath, timeoutMs, async () => {
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
let lockPath = `${lockTargetPath}.lock`;
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
let acquired = false;
while (!acquired) {
try {
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
encoding: 'utf8',
flag: 'wx',
});
acquired = true;
break;
} catch (error) {
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
lockPath = fallbackLockPath;
continue;
}
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
if (await removeRecoverableLock(lockPath)) {
continue;
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
}
await sleep(LOCK_POLL_MS);
}
}
try {
return await task();
} finally {
await fs.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
});
}
});
}
function isPermissionError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === 'EACCES' || code === 'EPERM';
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function withLocalLock<T>(key: string, timeoutMs: number, task: () => Promise<T>): Promise<T> {
const previous = localLockTails.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => current);
localLockTails.set(key, tail);
try {
await waitForLocalLock(previous, timeoutMs, key);
return await task();
} finally {
release();
void tail.then(() => {
if (localLockTails.get(key) === tail) {
localLockTails.delete(key);
}
});
}
}
async function waitForLocalLock(previous: Promise<void>, timeoutMs: number, key: string): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
previous,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`Timed out waiting for file lock ${key}.lock`)),
Math.max(0, timeoutMs)
);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function resolveAtomicWriteTarget(filePath: string): Promise<{ path: string; mode?: number }> {
try {
const stats = await fs.lstat(filePath);
if (stats.isSymbolicLink()) {
const targetPath = await resolvePathFollowingSymlinks(filePath);
return { path: targetPath, mode: await readMode(targetPath) };
}
return { path: filePath, mode: stats.mode & 0o777 };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { path: filePath };
}
throw error;
}
}
async function resolvePathFollowingSymlinks(filePath: string): Promise<string> {
let currentPath = await canonicalizeParentDirectory(filePath);
for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth += 1) {
let stats;
try {
stats = await fs.lstat(currentPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return await canonicalizeParentDirectory(currentPath);
}
throw error;
}
if (!stats.isSymbolicLink()) {
return currentPath;
}
const link = await fs.readlink(currentPath);
currentPath = await canonicalizeParentDirectory(
path.isAbsolute(link) ? link : path.resolve(path.dirname(currentPath), link)
);
}
throw new Error(`Too many symbolic links while resolving ${filePath}`);
}
async function canonicalizeParentDirectory(filePath: string): Promise<string> {
try {
return path.join(await fs.realpath(path.dirname(filePath)), path.basename(filePath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return filePath;
}
throw error;
}
}
async function readMode(filePath: string): Promise<number | undefined> {
try {
const stats = await fs.stat(filePath);
return stats.mode & 0o777;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
throw error;
}
}
async function removeRecoverableLock(lockPath: string): Promise<boolean> {
const breakerPath = `${lockPath}.break`;
try {
await fs.writeFile(breakerPath, `${process.pid}\n${new Date().toISOString()}\n`, {
encoding: 'utf8',
flag: 'wx',
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
return false;
}
if (!(await isLockRecoverable(breakerPath))) {
return false;
}
await fs.unlink(breakerPath).catch(() => {});
return false;
}
try {
if (!(await isLockRecoverable(lockPath))) {
return false;
}
await fs.unlink(lockPath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
} finally {
await fs.unlink(breakerPath).catch(() => {});
}
}
async function isLockRecoverable(lockPath: string): Promise<boolean> {
let contents: string;
try {
contents = await fs.readFile(lockPath, 'utf8');
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
if (contents.length === 0) {
return await isMalformedLockStale(lockPath);
}
const pid = Number.parseInt(contents.split(/\r?\n/, 1)[0] ?? '', 10);
if (Number.isInteger(pid) && pid > 0) {
return !isProcessRunning(pid);
}
return await isMalformedLockStale(lockPath);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
async function isMalformedLockStale(lockPath: string): Promise<boolean> {
try {
const stats = await fs.stat(lockPath);
return Date.now() - stats.mtimeMs > MALFORMED_LOCK_STALE_MS;
} catch {
return false;
}
}

View File

@ -1,4 +1,5 @@
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import {
bundleOutput,
@ -10,8 +11,10 @@ import { ensureInvocationDefaults, fetchTools, resolveServerDefinition } from '.
import { resolveRuntimeKind } from './cli/generate/runtime.js';
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
import type { ToolMetadata } from './cli/generate/tools.js';
import { buildToolMetadata, toolsTestHelpers } from './cli/generate/tools.js';
import { buildToolMetadataList, toolsTestHelpers } from './cli/generate/tools.js';
import { type CliArtifactMetadata, serializeDefinition } from './cli-metadata.js';
import { stableJsonStringify } from './cli/generate/stable-json.js';
import type { ServerDefinition } from './config.js';
import type { ServerToolInfo } from './runtime.js';
export interface GenerateCliOptions {
@ -29,6 +32,8 @@ export interface GenerateCliOptions {
readonly excludeTools?: string[];
}
const REPRODUCIBLE_GENERATED_AT = '1970-01-01T00:00:00.000Z';
// generateCli produces a standalone CLI (and optional bundle/binary) for a given MCP server.
export async function generateCli(
options: GenerateCliOptions
@ -55,7 +60,9 @@ export async function generateCli(
baseDefinition.description || !derivedDescription
? baseDefinition
: { ...baseDefinition, description: derivedDescription };
const toolMetadata: ToolMetadata[] = tools.map((tool) => buildToolMetadata(tool));
const embeddedDefinition = stripBuildSources(definition);
const serializedDefinition = serializeDefinition(embeddedDefinition);
const toolMetadata: ToolMetadata[] = buildToolMetadataList(tools);
const generator = await readPackageMetadata();
const baseInvocation = ensureInvocationDefaults(
{
@ -72,34 +79,31 @@ export async function generateCli(
includeTools: options.includeTools,
excludeTools: options.excludeTools,
},
definition
embeddedDefinition
);
const embeddedMetadata: CliArtifactMetadata = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
generatedAt: REPRODUCIBLE_GENERATED_AT,
generator,
server: {
name,
source: definition.source,
definition: serializeDefinition(definition),
definition: serializedDefinition,
},
artifact: {
path: '',
kind: 'template',
},
invocation: baseInvocation,
invocation: buildEmbeddedInvocation(baseInvocation, serializedDefinition),
};
const shouldBundle = Boolean(options.bundle ?? options.compile);
let templateTmpDir: string | undefined;
let templateOutputPath = options.outputPath;
if (!templateOutputPath && options.compile) {
const tmpPrefix = path.join(process.cwd(), 'tmp', 'mcporter-cli-');
await fs.mkdir(path.dirname(tmpPrefix), { recursive: true });
templateTmpDir = await fs.mkdtemp(tmpPrefix);
templateOutputPath = path.join(templateTmpDir, `${name}.ts`);
if (!templateOutputPath && shouldBundle) {
templateTmpDir = resolveImplicitTemplateDir(name, serializedDefinition);
await fs.mkdir(templateTmpDir, { recursive: true });
templateOutputPath = path.join(templateTmpDir, `${sanitizePathSegment(name) || 'server'}.ts`);
}
const shouldBundle = Boolean(options.bundle ?? options.compile);
const templateSourcePath = path.resolve(templateOutputPath ?? path.resolve(process.cwd(), `${name}.ts`));
let resolvedBundleTarget: string | undefined;
let resolvedCompileTarget: string | undefined;
@ -122,7 +126,7 @@ export async function generateCli(
runtimeScriptPath,
runtimeKind,
timeoutMs,
definition,
definition: embeddedDefinition,
serverName: name,
tools: toolMetadata,
generator,
@ -157,13 +161,49 @@ export async function generateCli(
}
} finally {
if (templateTmpDir) {
await fs.rm(templateTmpDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(outputPath, { force: true }).catch(() => {});
await fs.rmdir(templateTmpDir).catch(() => {});
await fs.rmdir(path.dirname(templateTmpDir)).catch(() => {});
}
}
return { outputPath: options.outputPath ?? outputPath, bundlePath, compilePath };
}
function stripBuildSources(definition: ServerDefinition): ServerDefinition {
const { source: _source, sources: _sources, ...runtimeDefinition } = definition;
return runtimeDefinition;
}
function buildEmbeddedInvocation(
invocation: CliArtifactMetadata['invocation'],
definition: ReturnType<typeof serializeDefinition>
): CliArtifactMetadata['invocation'] {
return {
serverRef: stableJsonStringify(definition),
runtime: invocation.runtime,
bundler: invocation.bundler,
timeoutMs: invocation.timeoutMs,
minify: invocation.minify,
includeTools: invocation.includeTools,
excludeTools: invocation.excludeTools,
bundle: typeof invocation.bundle === 'boolean' ? invocation.bundle : undefined,
compile: invocation.compile,
};
}
function resolveImplicitTemplateDir(serverName: string, definition: ReturnType<typeof serializeDefinition>): string {
const hash = createHash('sha256').update(stableJsonStringify({ serverName, definition })).digest('hex').slice(0, 12);
return path.join(process.cwd(), 'tmp', 'mcporter-cli', `${sanitizePathSegment(serverName) || 'server'}-${hash}`);
}
function sanitizePathSegment(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function applyToolFilters(tools: ServerToolInfo[], includeTools?: string[], excludeTools?: string[]): ServerToolInfo[] {
if (includeTools && excludeTools) {
throw new Error('Internal error: both includeTools and excludeTools provided to generateCli.');

View File

@ -80,6 +80,8 @@ function serializeRawEntry(server: ServerDefinition): RawEntry {
...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}),
...(server.oauthScope ? { oauthScope: server.oauthScope } : {}),
...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}),
...(server.refresh ? { refresh: server.refresh } : {}),
...(server.httpFetch ? { httpFetch: server.httpFetch } : {}),
...(server.lifecycle ? { lifecycle: serializeLifecycle(server.lifecycle) } : {}),
...(server.logging ? { logging: server.logging } : {}),
...(server.allowedTools ? { allowedTools: [...server.allowedTools] } : {}),

View File

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

View File

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

33
src/oauth-client-info.ts Normal file
View File

@ -0,0 +1,33 @@
import type { OAuthClientInformationMixed } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
export function buildStaticClientInformation(
definition: ServerDefinition,
options: { redirectUrl?: URL | string } = {}
): OAuthClientInformationMixed | undefined {
if (!definition.oauthClientId) {
return undefined;
}
const clientSecret = resolveOAuthClientSecret(definition);
return {
client_id: definition.oauthClientId,
...(clientSecret ? { client_secret: clientSecret } : {}),
...(options.redirectUrl ? { redirect_uris: [options.redirectUrl.toString()] } : {}),
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(definition.oauthTokenEndpointAuthMethod
? { token_endpoint_auth_method: definition.oauthTokenEndpointAuthMethod }
: {}),
} as OAuthClientInformationMixed;
}
export function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
if (definition.oauthClientSecretEnv) {
const value = process.env[definition.oauthClientSecretEnv];
if (!value) {
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
}
return value;
}
return definition.oauthClientSecret;
}

View File

@ -1,10 +1,18 @@
import fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import os from 'node:os';
import path from 'node:path';
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import { discoverOAuthServerInfo, refreshAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
import type {
OAuthClientInformationMixed,
OAuthProtectedResourceMetadata,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk/shared/auth-utils.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import { readJsonFile, writeJsonFile, writeTextFileAtomic } from './fs-json.js';
import type { Logger } from './logging.js';
import { buildStaticClientInformation } from './oauth-client-info.js';
import { clearVaultEntry, getOAuthVaultPath, loadVaultEntry, saveVaultEntry } from './oauth-vault.js';
import { legacyMcporterDir } from './paths.js';
@ -23,6 +31,102 @@ export interface OAuthPersistence {
clear(scope: OAuthClearScope): Promise<void>;
}
type StoredOAuthTokens = OAuthTokens & {
expires_at?: number;
expiresAt?: number;
};
const TOKEN_EXPIRY_SKEW_SECONDS = 60;
function withStoredExpiry(tokens: OAuthTokens): OAuthTokens {
const stored = tokens as StoredOAuthTokens;
if (typeof stored.expires_at === 'number' || typeof stored.expiresAt === 'number') {
return tokens;
}
if (typeof tokens.expires_in === 'number' && Number.isFinite(tokens.expires_in)) {
return {
...tokens,
expires_at: Math.floor(Date.now() / 1000) + tokens.expires_in,
} as OAuthTokens;
}
return tokens;
}
function tokenExpirySeconds(tokens: OAuthTokens): number | undefined {
const stored = tokens as StoredOAuthTokens;
for (const candidate of [stored.expires_at, stored.expiresAt]) {
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
return candidate;
}
}
return undefined;
}
function cachedTokensChanged(original: OAuthTokens, current: OAuthTokens | undefined): boolean {
if (!current || typeof current.access_token !== 'string' || current.access_token.trim().length === 0) {
return false;
}
if (typeof original.refresh_token === 'string' && typeof current.refresh_token === 'string') {
return current.refresh_token !== original.refresh_token || current.access_token !== original.access_token;
}
return current.access_token !== original.access_token;
}
function shouldRefreshCachedToken(tokens: OAuthTokens, skewSeconds = TOKEN_EXPIRY_SKEW_SECONDS): boolean {
const expiresAt = tokenExpirySeconds(tokens);
if (expiresAt !== undefined) {
return expiresAt <= Math.floor(Date.now() / 1000) + skewSeconds;
}
return typeof tokens.expires_in === 'number' && typeof tokens.refresh_token === 'string';
}
function resourceForRefresh(
serverUrl: URL,
resourceMetadata: OAuthProtectedResourceMetadata | undefined
): URL | undefined {
if (!resourceMetadata) {
return undefined;
}
const defaultResource = resourceUrlFromServerUrl(serverUrl);
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
throw new Error(
`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`
);
}
return new URL(resourceMetadata.resource);
}
function unrecoverableOAuthRefreshCode(error: unknown): string | undefined {
const errorCode = oauthErrorCode(error);
if (errorCode && ['invalid_client', 'invalid_grant', 'unauthorized_client'].includes(errorCode)) {
return errorCode;
}
return undefined;
}
function oauthErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
const { errorCode, name } = error as { errorCode?: unknown; name?: unknown };
if (typeof errorCode === 'string' && errorCode.length > 0) {
return errorCode.toLowerCase();
}
if (typeof name === 'string') {
const normalized = name.toLowerCase();
if (normalized === 'invalidclienterror') {
return 'invalid_client';
}
if (normalized === 'invalidgranterror') {
return 'invalid_grant';
}
if (normalized === 'unauthorizedclienterror') {
return 'unauthorized_client';
}
}
return undefined;
}
class DirectoryPersistence implements OAuthPersistence {
private readonly tokenPath: string;
private readonly clientInfoPath: string;
@ -48,17 +152,17 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readTokens(): Promise<OAuthTokens | undefined> {
return readJsonFile<OAuthTokens>(this.tokenPath);
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.tokenPath, tokens);
await writeJsonFile(this.tokenPath, withStoredExpiry(tokens));
this.logger?.debug?.(`Saved tokens to ${this.tokenPath}`);
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
@ -79,13 +183,35 @@ class DirectoryPersistence implements OAuthPersistence {
async saveCodeVerifier(value: string): Promise<void> {
await this.ensureDir();
await fs.writeFile(this.codeVerifierPath, value, 'utf8');
await writeTextFileAtomic(this.codeVerifierPath, value);
}
async readState(): Promise<string | undefined> {
// Deliberately NOT corrupt-tolerant: a corrupt OAuth state must fail the
// flow closed. Returning undefined here would skip the CSRF state check on
// the authorization callback (see oauth.ts), so only the credential caches
// (tokens/client) degrade to re-auth.
return readJsonFile<string>(this.statePath);
}
// A present-but-corrupt credential cache (tokens/client) means "no usable
// credentials": degrade to re-auth instead of crashing the connection,
// mirroring VaultPersistence and the daemon/server-proxy readers. Genuine I/O
// faults still propagate (readJsonFile re-throws everything except ENOENT).
// OAuth state is intentionally excluded (see readState) so its CSRF check
// still fails closed on a corrupt state file.
private async readJsonOrUndefined<T>(filePath: string): Promise<T | undefined> {
try {
return await readJsonFile<T>(filePath);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
this.logger?.debug?.(`Ignoring corrupt OAuth cache file ${filePath}: ${error.message}`);
return undefined;
}
}
async saveState(value: string): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.statePath, value);
@ -131,7 +257,7 @@ class VaultPersistence implements OAuthPersistence {
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await saveVaultEntry(this.definition, { tokens });
await saveVaultEntry(this.definition, { tokens: withStoredExpiry(tokens) });
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
@ -281,7 +407,7 @@ export async function clearOAuthCaches(
await legacy.clear(scope);
}
if (definition.tokenCacheDir) {
if (definition.tokenCacheDir && scope === 'all') {
await fs.rm(definition.tokenCacheDir, { recursive: true, force: true });
}
@ -310,8 +436,220 @@ export async function readCachedAccessToken(
): Promise<string | undefined> {
const persistence = await buildOAuthPersistence(definition, logger);
const tokens = await persistence.readTokens();
if (tokens && typeof tokens.access_token === 'string' && tokens.access_token.trim().length > 0) {
if (!tokens || typeof tokens.access_token !== 'string' || tokens.access_token.trim().length === 0) {
return undefined;
}
if (definition.auth === 'refreshable_bearer') {
return await readExplicitRefreshableBearerToken(definition, persistence, tokens, logger);
}
if (!shouldRefreshCachedToken(tokens)) {
return tokens.access_token;
}
if (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.trim().length === 0) {
return tokens.access_token;
}
try {
const clientInformation = buildStaticClientInformation(definition) ?? (await persistence.readClientInfo());
if (!clientInformation) {
logger?.debug?.(
`Cached OAuth token for '${definition.name}' is expired, but no client information is available.`
);
return tokens.access_token;
}
if (definition.command.kind !== 'http') {
return tokens.access_token;
}
const serverInfo = await discoverOAuthServerInfo(definition.command.url);
const resource = resourceForRefresh(definition.command.url, serverInfo.resourceMetadata);
const refreshed = await refreshAuthorization(serverInfo.authorizationServerUrl, {
metadata: serverInfo.authorizationServerMetadata,
clientInformation,
refreshToken: tokens.refresh_token,
...(resource ? { resource } : {}),
});
await persistence.saveTokens(refreshed);
logger?.debug?.(`Refreshed cached OAuth access token for '${definition.name}' (non-interactive).`);
return refreshed.access_token;
} catch (error) {
logger?.debug?.(
`Failed to refresh cached OAuth token for '${definition.name}' non-interactively: ${
error instanceof Error ? error.message : String(error)
}`
);
const unrecoverableCode = unrecoverableOAuthRefreshCode(error);
if (unrecoverableCode) {
const latestTokens = await persistence.readTokens();
if (cachedTokensChanged(tokens, latestTokens)) {
logger?.debug?.(`Kept cached OAuth token for '${definition.name}' because another refresh updated it first.`);
return latestTokens?.access_token;
}
const scope = unrecoverableCode === 'invalid_grant' ? 'tokens' : 'all';
await clearOAuthCaches(definition, logger, scope);
logger?.debug?.(
`Cleared cached OAuth ${scope === 'all' ? 'credentials' : 'token'} for '${
definition.name
}' after unrecoverable refresh failure.`
);
return undefined;
}
return tokens.access_token;
}
return undefined;
}
async function readExplicitRefreshableBearerToken(
definition: ServerDefinition,
persistence: OAuthPersistence,
tokens: OAuthTokens,
logger?: Logger
): Promise<string> {
const refresh = definition.refresh;
const skewSeconds = refresh?.refreshSkewSeconds ?? TOKEN_EXPIRY_SKEW_SECONDS;
if (!shouldRefreshCachedToken(tokens, skewSeconds)) {
return tokens.access_token;
}
if (!refresh) {
throw new Error(`Cached bearer token for '${definition.name}' is expired, but refresh is not configured.`);
}
if (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.trim().length === 0) {
throw new Error(`Cached bearer token for '${definition.name}' is expired, but no refresh_token is available.`);
}
try {
const refreshed = await refreshBearerToken(definition, tokens.refresh_token);
await persistence.saveTokens(refreshed);
logger?.debug?.(`Refreshed bearer access token for '${definition.name}' (non-interactive).`);
return refreshed.access_token;
} catch (error) {
logger?.debug?.(
`Failed to refresh bearer token for '${definition.name}' non-interactively: ${
error instanceof Error ? error.message : String(error)
}`
);
throw new Error(
`Failed to refresh cached bearer token for '${definition.name}': ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
);
}
}
async function refreshBearerToken(definition: ServerDefinition, refreshToken: string): Promise<OAuthTokens> {
const refresh = definition.refresh;
if (!refresh) {
throw new Error('Missing refresh configuration.');
}
const clientId = readEnvOrConfig(refresh.clientIdEnv, definition.oauthClientId);
const method = refresh.clientAuthMethod ?? definition.oauthTokenEndpointAuthMethod ?? 'client_secret_basic';
const clientSecret = method === 'none' ? undefined : readClientSecret(definition, refresh.clientSecretEnv);
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const headers: Record<string, string> = {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
};
if (method === 'client_secret_post') {
if (clientId) {
body.set('client_id', clientId);
}
if (clientSecret) {
body.set('client_secret', clientSecret);
}
} else if (method === 'none') {
if (clientId) {
body.set('client_id', clientId);
}
} else {
if (!clientId || !clientSecret) {
throw new Error(`Refresh client credentials are required for '${method}'.`);
}
headers.authorization = `Basic ${Buffer.from(
`${formEncodeCredential(clientId)}:${formEncodeCredential(clientSecret)}`
).toString('base64')}`;
}
const response = await fetch(refresh.tokenEndpoint, {
method: 'POST',
headers,
body,
});
if (!response.ok) {
throw new Error(`Token endpoint returned HTTP ${response.status}.`);
}
const payload = normalizeBearerTokenResponse(await response.json());
return {
...payload,
...(payload.refresh_token ? {} : { refresh_token: refreshToken }),
};
}
function normalizeBearerTokenResponse(value: unknown): OAuthTokens {
if (!value || typeof value !== 'object') {
throw new Error('Token endpoint did not return a JSON object.');
}
const payload = value as Record<string, unknown>;
if (typeof payload.access_token !== 'string' || payload.access_token.trim().length === 0) {
throw new Error('Token endpoint did not return an access_token.');
}
return {
access_token: payload.access_token,
token_type: typeof payload.token_type === 'string' && payload.token_type ? payload.token_type : 'Bearer',
...(typeof payload.id_token === 'string' ? { id_token: payload.id_token } : {}),
...(typeof payload.scope === 'string' ? { scope: payload.scope } : {}),
...(typeof payload.refresh_token === 'string' && payload.refresh_token
? { refresh_token: payload.refresh_token }
: {}),
...coerceExpiresIn(payload.expires_in),
};
}
function coerceExpiresIn(value: unknown): Pick<OAuthTokens, 'expires_in'> {
if (typeof value === 'number' && Number.isFinite(value)) {
return { expires_in: value };
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return { expires_in: parsed };
}
}
return {};
}
function readEnvOrConfig(envName: string | undefined, fallback: string | undefined): string | undefined {
if (!envName) {
return fallback;
}
const value = process.env[envName];
if (value === undefined || value.trim().length === 0) {
throw new Error(`Environment variable '${envName}' is required for bearer token refresh.`);
}
return value;
}
function formEncodeCredential(value: string): string {
return new URLSearchParams([['', value]]).toString().slice(1);
}
function readClientSecret(
definition: ServerDefinition,
refreshClientSecretEnv: string | undefined
): string | undefined {
if (refreshClientSecretEnv) {
return readEnvOrConfig(refreshClientSecretEnv, undefined);
}
return resolveOAuthClientSecret(definition);
}
function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
if (definition.oauthClientSecretEnv) {
const value = process.env[definition.oauthClientSecretEnv];
if (value === undefined || value.trim().length === 0) {
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
}
return value;
}
return definition.oauthClientSecret;
}

View File

@ -1,9 +1,8 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import { readJsonFile, withFileLock, writeJsonFile } from './fs-json.js';
import { mcporterDir } from './paths.js';
type VaultKey = string;
@ -23,35 +22,49 @@ interface VaultFile {
entries: Record<VaultKey, VaultEntry>;
}
interface VaultReadState {
vault: VaultFile;
needsRepair: boolean;
}
interface SameUrlCredentials {
tokens?: OAuthTokens;
clientInfo?: OAuthClientInformationMixed;
sourceKeys: VaultKey[];
}
export function getOAuthVaultPath(): string {
return path.join(mcporterDir('data'), 'credentials.json');
}
async function readVault(): Promise<VaultFile> {
let shouldRewrite = false;
async function readVaultState(): Promise<VaultReadState> {
try {
const existing = await readJsonFile<VaultFile>(getOAuthVaultPath());
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === 'object') {
return existing;
return { vault: existing, needsRepair: false };
}
// Unexpected shape; rewrite.
shouldRewrite = true;
} catch {
// Corrupt or unreadable vault; reset to empty.
shouldRewrite = true;
if (existing !== undefined) {
return { vault: emptyVault(), needsRepair: true };
}
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
return { vault: emptyVault(), needsRepair: true };
}
const empty: VaultFile = { version: 1, entries: {} };
if (shouldRewrite) {
await writeVault(empty);
}
return empty;
return { vault: emptyVault(), needsRepair: false };
}
async function readVault(): Promise<VaultFile> {
return (await readVaultState()).vault;
}
function emptyVault(): VaultFile {
return { version: 1, entries: {} };
}
async function writeVault(contents: VaultFile): Promise<void> {
const filePath = getOAuthVaultPath();
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await writeJsonFile(filePath, contents);
await writeJsonFile(getOAuthVaultPath(), contents);
}
export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
@ -69,53 +82,179 @@ export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
const vault = await readVault();
return vault.entries[vaultKeyForDefinition(definition)];
const key = vaultKeyForDefinition(definition);
const exact = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
const fallback = findSameUrlCredentials(vault, definition, key, exact);
if (!fallback.tokens && !fallback.clientInfo) {
return exact;
}
if (!exact) {
return {
serverName: definition.name,
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
updatedAt: new Date().toISOString(),
tokens: fallback.tokens,
clientInfo: fallback.clientInfo,
};
}
return {
...exact,
tokens: exact.tokens ?? fallback.tokens,
clientInfo: exact.clientInfo ?? (exact.tokens ? undefined : fallback.clientInfo),
};
}
function findSameUrlCredentials(
vault: VaultFile,
definition: ServerDefinition,
exactKey: VaultKey,
exact: VaultEntry | undefined
): SameUrlCredentials {
if (definition.command.kind !== 'http') {
return { sourceKeys: [] };
}
const serverUrl = definition.command.url.toString();
const candidates = Object.entries(vault.entries)
.filter(
([key, entry]) =>
key !== exactKey &&
isVaultEntry(entry) &&
entry.serverUrl === serverUrl &&
isLegacyOAuthRenameCandidate(definition, entry) &&
(entry.tokens || entry.clientInfo)
)
.map(([key, entry]) => ({ key, entry }))
.toSorted((a, b) => Date.parse(b.entry.updatedAt) - Date.parse(a.entry.updatedAt));
const requiredClientId = definition.oauthClientId ?? clientIdFromEntry(exact);
if (requiredClientId) {
const tokenSource = candidates.find(
({ entry }) => (entry.tokens || entry.clientInfo) && clientIdFromEntry(entry) === requiredClientId
);
return {
tokens: tokenSource?.entry.tokens,
clientInfo: exact?.clientInfo ? undefined : tokenSource?.entry.clientInfo,
sourceKeys: tokenSource ? [tokenSource.key] : [],
};
}
const source = candidates.find(({ entry }) => entry.clientInfo && clientIdFromEntry(entry));
return {
tokens: source?.entry.tokens,
clientInfo: source?.entry.clientInfo,
sourceKeys: source ? [source.key] : [],
};
}
function isLegacyOAuthRenameCandidate(definition: ServerDefinition, entry: VaultEntry): boolean {
return entry.serverName === `${definition.name}-oauth`;
}
function legacyOAuthRenameKeys(vault: VaultFile, definition: ServerDefinition, exactKey: VaultKey): VaultKey[] {
if (definition.command.kind !== 'http') {
return [];
}
const serverUrl = definition.command.url.toString();
return Object.entries(vault.entries)
.filter(
([key, entry]) =>
key !== exactKey &&
isVaultEntry(entry) &&
entry.serverUrl === serverUrl &&
isLegacyOAuthRenameCandidate(definition, entry)
)
.map(([key]) => key);
}
function isVaultEntry(entry: unknown): entry is VaultEntry {
return Boolean(
entry &&
typeof entry === 'object' &&
typeof (entry as VaultEntry).serverName === 'string' &&
typeof (entry as VaultEntry).updatedAt === 'string'
);
}
function clientIdFromEntry(entry: VaultEntry | undefined): string | undefined {
const clientId = entry?.clientInfo?.client_id;
return typeof clientId === 'string' && clientId.length > 0 ? clientId : undefined;
}
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const current = vault.entries[key] ?? {
serverName: definition.name,
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
updatedAt: new Date().toISOString(),
};
vault.entries[key] = {
...current,
...patch,
updatedAt: new Date().toISOString(),
};
await writeVault(vault);
await withFileLock(getOAuthVaultPath(), async () => {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
const fallback = findSameUrlCredentials(vault, definition, key, existing);
const current = existing ?? {
serverName: definition.name,
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
updatedAt: new Date().toISOString(),
};
vault.entries[key] = {
...current,
...patch,
clientInfo:
patch.clientInfo ?? current.clientInfo ?? (patch.tokens && !current.tokens ? fallback.clientInfo : undefined),
updatedAt: new Date().toISOString(),
};
await writeVault(vault);
});
}
export async function clearVaultEntry(
definition: ServerDefinition,
scope: 'all' | 'tokens' | 'client' | 'verifier' | 'state'
): Promise<void> {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const existing = vault.entries[key];
if (!existing) {
return;
}
if (scope === 'all') {
delete vault.entries[key];
} else {
const updated: VaultEntry = { ...existing };
if (scope === 'tokens') {
delete updated.tokens;
await withFileLock(getOAuthVaultPath(), async () => {
const { vault, needsRepair } = await readVaultState();
const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined;
const fallback = findSameUrlCredentials(vault, definition, key, existing);
const inheritedKeys = scope === 'all' ? legacyOAuthRenameKeys(vault, definition, key) : fallback.sourceKeys;
if (!existing && inheritedKeys.length === 0) {
if (needsRepair) {
await writeVault(vault);
}
return;
}
if (scope === 'client') {
delete updated.clientInfo;
if (scope === 'all') {
delete vault.entries[key];
} else if (existing) {
const updated: VaultEntry = { ...existing };
if (scope === 'tokens') {
delete updated.tokens;
}
if (scope === 'client') {
delete updated.clientInfo;
}
if (scope === 'verifier') {
delete updated.codeVerifier;
}
if (scope === 'state') {
delete updated.state;
}
updated.updatedAt = new Date().toISOString();
vault.entries[key] = updated;
}
if (scope === 'verifier') {
delete updated.codeVerifier;
for (const fallbackKey of inheritedKeys) {
const inherited = vault.entries[fallbackKey];
if (!inherited) {
continue;
}
if (scope === 'all') {
delete vault.entries[fallbackKey];
continue;
}
const updated: VaultEntry = { ...inherited };
if (scope === 'tokens') {
delete updated.tokens;
}
if (scope === 'client') {
delete updated.clientInfo;
}
updated.updatedAt = new Date().toISOString();
vault.entries[fallbackKey] = updated;
}
if (scope === 'state') {
delete updated.state;
}
updated.updatedAt = new Date().toISOString();
vault.entries[key] = updated;
}
await writeVault(vault);
await writeVault(vault);
});
}

View File

@ -9,12 +9,23 @@ import type {
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
import { buildStaticClientInformation } from './oauth-client-info.js';
import type { OAuthPersistence } from './oauth-persistence.js';
import { buildOAuthPersistence } from './oauth-persistence.js';
const CALLBACK_HOST = '127.0.0.1';
const CALLBACK_PATH = '/callback';
export interface OAuthAuthorizationRequest {
authorizationUrl: string;
redirectUrl: string;
}
export interface OAuthSessionOptions {
suppressBrowserLaunch?: boolean;
onAuthorizationUrl?: (request: OAuthAuthorizationRequest) => void | Promise<void>;
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
@ -74,7 +85,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
private readonly definition: ServerDefinition,
persistence: OAuthPersistence,
redirectUrl: URL,
logger: OAuthLogger
logger: OAuthLogger,
private readonly options: OAuthSessionOptions = {}
) {
this.redirectUrlValue = redirectUrl;
this.logger = logger;
@ -96,7 +108,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
static async create(
definition: ServerDefinition,
logger: OAuthLogger
logger: OAuthLogger,
options: OAuthSessionOptions = {}
): Promise<{
provider: PersistentOAuthClientProvider;
close: () => Promise<void>;
@ -137,8 +150,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
// previous client registration is cached with a different redirect URI the
// auth server will reject the request with `invalid_redirect_uri`. Clear
// the stale registration so the next flow re-registers with the new URI.
// Wrapped in try/catch so persistence errors (malformed JSON, permission
// issues) close the already-bound callback server instead of leaking it.
// Wrapped in try/catch so non-recoverable persistence errors (for example,
// permission issues) close the already-bound callback server instead of leaking it.
if (usesDynamicPort) {
try {
const cachedClient = await persistence.readClientInfo();
@ -157,7 +170,7 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
}
}
const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger);
const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger, options);
provider.attachServer(server);
return {
provider,
@ -184,7 +197,7 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
const error = parsed.searchParams.get('error');
const receivedState = parsed.searchParams.get('state');
const expectedState = await this.persistence.readState();
if (expectedState && receivedState && receivedState !== expectedState) {
if (expectedState && receivedState !== expectedState) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html');
res.end('<html><body><h1>Authorization failed</h1><p>Invalid OAuth state</p></body></html>');
@ -237,7 +250,7 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
}
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
const staticClient = buildStaticClientInformation(this.definition, this.redirectUrlValue);
const staticClient = buildStaticClientInformation(this.definition, { redirectUrl: this.redirectUrlValue });
if (staticClient) {
return staticClient;
}
@ -258,11 +271,19 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
this.logger.info(`Authorization required for ${this.definition.name}. Opening browser...`);
this.authorizationRedirectStarted = true;
this.ensureAuthorizationDeferred();
__oauthInternals.openExternal(authorizationUrl.toString());
this.logger.warn(`If the browser did not open, visit ${authorizationUrl.toString()} manually.`);
const request = {
authorizationUrl: authorizationUrl.toString(),
redirectUrl: this.redirectUrlValue.toString(),
} satisfies OAuthAuthorizationRequest;
if (this.options.suppressBrowserLaunch) {
await this.options.onAuthorizationUrl?.(request);
return;
}
this.logger.info(`Authorization required for ${this.definition.name}. Opening browser...`);
__oauthInternals.openExternal(request.authorizationUrl);
this.logger.warn(`If the browser did not open, visit ${request.authorizationUrl} manually.`);
}
hasAuthorizationRedirectStarted(): boolean {
@ -302,10 +323,12 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
if (!this.server) {
return;
}
await new Promise<void>((resolve) => {
this.server?.close(() => resolve());
});
this.server.closeAllConnections?.();
const server = this.server;
this.server = undefined;
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
private ensureAuthorizationDeferred(): Deferred<string> {
@ -327,8 +350,12 @@ export interface OAuthSession {
}
// createOAuthSession spins up a file-backed OAuth provider and callback server for the target definition.
export async function createOAuthSession(definition: ServerDefinition, logger: OAuthLogger): Promise<OAuthSession> {
const { provider, close } = await PersistentOAuthClientProvider.create(definition, logger);
export async function createOAuthSession(
definition: ServerDefinition,
logger: OAuthLogger,
options: OAuthSessionOptions = {}
): Promise<OAuthSession> {
const { provider, close } = await PersistentOAuthClientProvider.create(definition, logger, options);
const waitForAuthorizationCode = () => provider.waitForAuthorizationCode();
const hasAuthorizationRedirectStarted = () => provider.hasAuthorizationRedirectStarted();
return {
@ -356,38 +383,6 @@ function firstRedirectUri(client: OAuthClientInformationMixed | undefined): stri
return typeof first === 'string' ? first : undefined;
}
function buildStaticClientInformation(
definition: ServerDefinition,
redirectUrl: URL
): OAuthClientInformationMixed | undefined {
if (!definition.oauthClientId) {
return undefined;
}
const clientSecret = resolveOAuthClientSecret(definition);
const metadata = {
client_id: definition.oauthClientId,
...(clientSecret ? { client_secret: clientSecret } : {}),
redirect_uris: [redirectUrl.toString()],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(definition.oauthTokenEndpointAuthMethod
? { token_endpoint_auth_method: definition.oauthTokenEndpointAuthMethod }
: {}),
};
return metadata as OAuthClientInformationMixed;
}
function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
if (definition.oauthClientSecretEnv) {
const value = process.env[definition.oauthClientSecretEnv];
if (!value) {
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
}
return value;
}
return definition.oauthClientSecret;
}
export const __oauthInternals = {
openExternal,
};

View File

@ -28,5 +28,10 @@ export function mcporterDir(kind: McporterPathKind): string {
export function mcporterConfigCandidates(): string[] {
const base = mcporterDir('config');
return [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')];
const candidates = [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')];
if (base !== legacyMcporterDir()) {
const legacy = legacyMcporterDir();
candidates.push(path.join(legacy, 'mcporter.json'), path.join(legacy, 'mcporter.jsonc'));
}
return candidates;
}

View File

@ -30,32 +30,37 @@ interface CollectedCallContent {
}
function extractEnvelope(raw: unknown): ExtractedEnvelope {
return collectEnvelopeFields(raw, { content: null, structuredContent: null }, 0);
}
function collectEnvelopeFields(raw: unknown, envelope: ExtractedEnvelope, depth: number): ExtractedEnvelope {
if (!raw || typeof raw !== 'object') {
return { content: null, structuredContent: null };
return envelope;
}
const obj = raw as Record<string, unknown>;
let content: unknown[] | null = null;
let structuredContent: unknown = null;
let { content, structuredContent } = envelope;
if ('content' in obj && Array.isArray(obj.content)) {
if (!content && 'content' in obj && Array.isArray(obj.content)) {
content = obj.content as unknown[];
}
if ('structuredContent' in obj) {
if (structuredContent === null && 'structuredContent' in obj) {
structuredContent = obj.structuredContent;
}
if ('raw' in obj && obj.raw && typeof obj.raw === 'object') {
const nested = obj.raw as Record<string, unknown>;
if (!content && 'content' in nested && Array.isArray(nested.content)) {
content = nested.content as unknown[];
}
if (structuredContent === null && 'structuredContent' in nested) {
structuredContent = nested.structuredContent;
}
const updated = { content, structuredContent };
if (depth >= 2) {
return updated;
}
return { content, structuredContent };
let nested = updated;
if ('raw' in obj) {
nested = collectEnvelopeFields(obj.raw, nested, depth + 1);
}
if ('result' in obj) {
nested = collectEnvelopeFields(obj.result, nested, depth + 1);
}
return nested;
}
// asString converts known content/value shapes into plain strings.

View File

@ -3,7 +3,7 @@ import { analyzeConnectionError } from './error-classifier.js';
import type { Logger } from './logging.js';
export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger): ServerDefinition | undefined {
if (definition.auth === 'oauth') {
if (definition.auth === 'oauth' || definition.auth === 'refreshable_bearer') {
return undefined;
}
if (definition.command.kind !== 'http') {

View File

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

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