Compare commits

...

21 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
87 changed files with 4672 additions and 937 deletions

View File

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

View File

@ -39,7 +39,7 @@ jobs:
runs-on: [self-hosted, crabbox, openclaw, mcporter, '${{ inputs.crabbox_runner_label }}']
timeout-minutes: 120
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}

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

View File

@ -1,18 +1,43 @@
# mcporter Changelog
## [0.11.4] - Unreleased
## [0.12.1] - 2026-06-18
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
### Config
- Skip imported server entries with unresolvable editor-specific environment placeholders, and allow later valid duplicates to take effect without relaxing validation for local config. (PR #209, thanks @Loveacup)
### OAuth
- Treat corrupt cached OAuth tokens and client metadata as missing so connections can re-authenticate, while keeping corrupt callback state data fail-closed. (Issue #207, thanks @KrasimirKralev)
### Tooling / Dependencies
- Refresh development dependencies and security overrides, including Vite, esbuild, and Hono.
## [0.12.0] - 2026-06-10
### OAuth
- Add cache-friendly `disableOAuth` support across headless runtime, CLI, daemon, proxy, and `callOnce` paths so callers can suppress interactive OAuth without losing connection reuse. (Issues #197, #199, #201, thanks @feniix)
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
- Prevent concurrent OAuth vault updates from briefly exposing empty lock files and losing credential entries under load.
### CLI
- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/<server>`, exposing one keep-alive server with original tool names while preserving aggregate `/mcp` namespacing. (PR #194, thanks @zm2231)
- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123)
- Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231)
- Return a non-zero exit code for explicit `mcporter list <unknown-server>` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674)
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
- Keep CloudBase MCP alive by default so device-code authentication can finish polling and persist credentials after returning `AUTH_PENDING`. (PR #193, thanks @sevzq)
- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr)
### Tooling / Dependencies
- Refresh development dependencies and satisfy the stricter `oxlint` check.
## [0.11.3] - 2026-05-21
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)

View File

@ -143,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
@ -163,6 +164,7 @@ 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.
@ -199,7 +201,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
- Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env.
- All other servers stay ephemeral; add `"lifecycle": "keep-alive"` to a server entry (or set `MCPORTER_KEEPALIVE=name`) when you want the daemon to manage it. You can also set `"lifecycle": "ephemeral"` (or `MCPORTER_DISABLE_KEEPALIVE=name`) to opt out.
- The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon.
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`.
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/<server>` for one selected keep-alive server with its original, unprefixed tool names.
- Troubleshooting? Run `mcporter daemon start --log` (or `--log-file /tmp/daemon.log`) to tee stdout/stderr into a file, and add `--log-servers chrome-devtools` when you only want call traces for a specific MCP. Per-server configs can also set `"logging": { "daemon": { "enabled": true } }` to force detailed logging for that entry.
## Friendlier Tool Calls
@ -254,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

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

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

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

View File

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

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

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

@ -1,6 +1,6 @@
{
"name": "mcporter",
"version": "0.11.3",
"version": "0.12.1",
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
"keywords": [
"cli",
@ -72,31 +72,31 @@
"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.1",
"ora": "^9.4.1",
"rolldown": "1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/estree": "^1.0.9",
"@types/express": "^5.0.6",
"@types/node": "^25.8.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@vitest/coverage-v8": "^4.1.6",
"@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.49.0",
"oxlint": "^1.64.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.22.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "8.0.13",
"vitest": "^4.1.6"
"vite": "8.0.16",
"vitest": "^4.1.9"
},
"devEngines": {
"runtime": [
@ -109,13 +109,5 @@
"engines": {
"node": ">=24"
},
"packageManager": "pnpm@10.33.2",
"pnpm": {
"overrides": {
"body-parser": "2.2.1",
"ip-address": "10.1.1",
"qs": "6.15.2",
"vite": "8.0.13"
}
}
"packageManager": "pnpm@10.33.2"
}

1418
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,5 +2,8 @@ onlyBuiltDependencies:
- esbuild
overrides:
body-parser: 2.2.1
esbuild: 0.28.1
hono: 4.12.25
ip-address: 10.1.1
vite: 8.0.13
qs: 6.15.2
vite: 8.0.16

View File

@ -74,16 +74,26 @@ export function metadataPathForArtifact(artifactPath: string): string {
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
// inspect command and falling back to legacy sidecar files.
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
let embeddedError: unknown;
try {
return await readMetadataFromCli(artifactPath);
} catch (error) {
embeddedError = error;
}
const legacyPath = metadataPathForArtifact(artifactPath);
try {
const buffer = await fs.readFile(legacyPath, 'utf8');
return JSON.parse(buffer) as CliArtifactMetadata;
} catch (error) {
if (isErrno(error, 'ENOENT') && embeddedError) {
throw embeddedError;
}
if (!isErrno(error, 'ENOENT')) {
throw error;
}
}
return await readMetadataFromCli(artifactPath);
throw embeddedError;
}
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {

View File

@ -239,16 +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');
@ -308,14 +308,15 @@ export async function runCli(argv: string[]): Promise<void> {
await importedHandleResource(runtime, resolvedArgs);
return;
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
} catch (error) {
primaryError = error;
throw error;
} finally {
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
}
async function closeRuntimeAfterCommand(
@ -479,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);
@ -583,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({
@ -590,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

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

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

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

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

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

View File

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

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

View File

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

View File

@ -266,5 +266,5 @@ function quoteCommandSegment(segment: string): string {
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
return segment;
}
return 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;

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

View File

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

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[]>>>();
@ -13,7 +14,8 @@ function cacheKey(serverName: string, options: LoadToolMetadataOptions): string
const includeSchema = options.includeSchema !== false;
const autoAuthorize = options.autoAuthorize !== false;
const allowCachedAuth = options.allowCachedAuth !== false;
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}`;
const disableOAuth = options.disableOAuth === true;
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}::disable-oauth:${disableOAuth ? '1' : '0'}`;
}
export async function loadToolMetadata(
@ -37,10 +39,11 @@ export async function loadToolMetadata(
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;

View File

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

View File

@ -58,10 +58,16 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
continue;
}
for (const [name, rawEntry] of entries) {
const source: ServerSource = { kind: 'import', path: resolved, importKind };
const baseDir = path.dirname(resolved);
try {
normalizeServerEntry(name, rawEntry, baseDir, source, [source]);
} catch {
continue;
}
if (merged.has(name)) {
continue;
}
const source: ServerSource = { kind: 'import', path: resolved, importKind };
const existing = merged.get(name);
// Keep the first-seen source as canonical while tracking all alternates
if (existing) {
@ -70,7 +76,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
}
merged.set(name, {
raw: rawEntry,
baseDir: path.dirname(resolved),
baseDir,
source,
sources: [source],
});
@ -99,7 +105,13 @@ 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;

View File

@ -452,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(
@ -503,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,
@ -554,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) {
@ -581,6 +595,7 @@ async function processRequest(
includeSchema: params.includeSchema,
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
allowCachedAuth: params.allowCachedAuth ?? true,
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
});
markActivity(params.server, activity);
if (loggable) {
@ -603,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}`);
@ -625,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}`);

View File

@ -28,6 +28,7 @@ export interface CallToolParams {
readonly tool: string;
readonly args?: Record<string, unknown>;
readonly timeoutMs?: number;
readonly disableOAuth?: boolean;
}
export interface ListToolsParams {
@ -35,16 +36,21 @@ export interface ListToolsParams {
readonly includeSchema?: boolean;
readonly autoAuthorize?: boolean;
readonly allowCachedAuth?: boolean;
readonly disableOAuth?: boolean;
}
export interface ListResourcesParams {
readonly server: string;
readonly params?: Record<string, unknown>;
readonly allowCachedAuth?: boolean;
readonly disableOAuth?: boolean;
}
export interface ReadResourceParams {
readonly server: string;
readonly uri: string;
readonly allowCachedAuth?: boolean;
readonly disableOAuth?: boolean;
}
export interface CloseServerParams {

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 {
@ -62,6 +68,7 @@ class KeepAliveRuntime implements Runtime {
includeSchema: options?.includeSchema,
autoAuthorize: options?.autoAuthorize,
allowCachedAuth: options?.allowCachedAuth ?? true,
disableOAuth: options?.disableOAuth,
})
)) as Awaited<ReturnType<Runtime['listTools']>>;
}
@ -76,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

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

View File

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

View File

@ -4,7 +4,10 @@ export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.j
export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js';
export type {
CallOptions,
ConnectOptions,
ListResourcesOptions,
ListToolsOptions,
ReadResourceOptions,
Runtime,
RuntimeLogger,
RuntimeOptions,

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'];

View File

@ -152,7 +152,7 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readTokens(): Promise<OAuthTokens | undefined> {
return readJsonFile<OAuthTokens>(this.tokenPath);
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
@ -162,7 +162,7 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
@ -187,9 +187,31 @@ class DirectoryPersistence implements OAuthPersistence {
}
async readState(): Promise<string | undefined> {
// Deliberately NOT corrupt-tolerant: a corrupt OAuth state must fail the
// flow closed. Returning undefined here would skip the CSRF state check on
// the authorization callback (see oauth.ts), so only the credential caches
// (tokens/client) degrade to re-auth.
return readJsonFile<string>(this.statePath);
}
// A present-but-corrupt credential cache (tokens/client) means "no usable
// credentials": degrade to re-auth instead of crashing the connection,
// mirroring VaultPersistence and the daemon/server-proxy readers. Genuine I/O
// faults still propagate (readJsonFile re-throws everything except ENOENT).
// OAuth state is intentionally excluded (see readState) so its CSRF check
// still fails closed on a corrupt state file.
private async readJsonOrUndefined<T>(filePath: string): Promise<T | undefined> {
try {
return await readJsonFile<T>(filePath);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
this.logger?.debug?.(`Ignoring corrupt OAuth cache file ${filePath}: ${error.message}`);
return undefined;
}
}
async saveState(value: string): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.statePath, value);

View File

@ -150,8 +150,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
// previous client registration is cached with a different redirect URI the
// auth server will reject the request with `invalid_redirect_uri`. Clear
// the stale registration so the next flow re-registers with the new URI.
// Wrapped in try/catch so 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();

View File

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

View File

@ -86,6 +86,14 @@ export interface CreateClientContextOptions {
readonly onDefinitionPromoted?: (definition: ServerDefinition) => void;
readonly allowCachedAuth?: boolean;
readonly oauthSessionOptions?: OAuthSessionOptions;
/**
* When `true`, suppress the interactive OAuth flow entirely. See
* `ConnectOptions.disableOAuth` in `runtime.ts` for the caller-facing
* semantics. Internally this short-circuits `shouldEstablishOAuth` and
* `maybePromoteHttpDefinition` so the unauthorized-fallback path
* cannot re-enable OAuth on a daemon-shaped caller.
*/
readonly disableOAuth?: boolean;
readonly recordPath?: string;
readonly replayPath?: string;
}
@ -188,7 +196,11 @@ function maybePromoteHttpDefinition(
logger: Logger,
options: CreateClientContextOptions
): ServerDefinition | undefined {
if (options.maxOAuthAttempts === 0) {
// Both flags suppress promotion-to-OAuth on a 401 fallback. Without
// this guard, a daemon-mode caller hitting an unauthorized response
// could trigger `maybeEnableOAuth` and effectively re-enable OAuth
// on the next attempt — defeating the no-browser-launch contract.
if (options.maxOAuthAttempts === 0 || options.disableOAuth === true) {
return undefined;
}
return maybeEnableOAuth(definition, logger);
@ -355,7 +367,8 @@ async function attemptHttpClientContext(
throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`);
}
let oauthSession: OAuthSession | undefined;
const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0;
const shouldEstablishOAuth =
activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0 && options.disableOAuth !== true;
if (shouldEstablishOAuth) {
oauthSession = await createOAuthSession(activeDefinition, logger, options.oauthSessionOptions);
}

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { parseCallArguments } from '../src/cli/call-arguments.js';
@ -93,6 +95,55 @@ describe('parseCallArguments', () => {
}
});
it('reads exact UTF-8 text from @path named argument values', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const payloadPath = path.join(tempDir, 'payload.txt');
fs.writeFileSync(payloadPath, 'first line\nsecond line\n', 'utf8');
try {
const parsed = parseCallArguments(['server.tool', `body=@${payloadPath}`]);
expect(parsed.args.body).toBe('first line\nsecond line\n');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('supports @path through generic long tool flags', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const payloadPath = path.join(tempDir, 'payload.txt');
fs.writeFileSync(payloadPath, 'from file', 'utf8');
try {
const parsed = parseCallArguments(['server.tool', '--body', `@${payloadPath}`]);
expect(parsed.args.body).toBe('from file');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('preserves whitespace-only generic long flag values', () => {
const parsed = parseCallArguments(['server.tool', '--body', ' ']);
expect(parsed.args.body).toBe(' ');
});
it('uses @@ to preserve a literal leading @ without reading a file', () => {
const parsed = parseCallArguments(['server.tool', 'body=@@literal']);
expect(parsed.args.body).toBe('@literal');
});
it('reports missing and non-UTF-8 argument files before transport', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const invalidPath = path.join(tempDir, 'invalid.bin');
fs.writeFileSync(invalidPath, Buffer.from([0xc3, 0x28]));
try {
expect(() => parseCallArguments(['server.tool', `body=@${path.join(tempDir, 'missing.txt')}`])).toThrow(
/Unable to read argument file/
);
expect(() => parseCallArguments(['server.tool', `body=@${invalidPath}`])).toThrow(/not valid UTF-8 text/);
expect(() => parseCallArguments(['server.tool', 'body=@'])).toThrow(/requires a path/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('throws when generic long flags are missing a value', () => {
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
});
@ -175,6 +226,12 @@ describe('parseCallArguments', () => {
expect(parsed.positionalArgs).toEqual(['123']);
});
it('captures --no-oauth as a runtime flag instead of a tool argument', () => {
const parsed = parseCallArguments(['server.tool', '--no-oauth', 'limit=5']);
expect(parsed.disableOAuth).toBe(true);
expect(parsed.args).toEqual({ limit: 5 });
});
it('captures --save-images output directory', () => {
const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
expect(parsed.saveImagesDir).toBe('./tmp/images');

View File

@ -86,6 +86,7 @@ describe('CLI call execution behavior', () => {
autoAuthorize: true,
includeSchema: true,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
@ -125,6 +126,7 @@ describe('CLI call execution behavior', () => {
autoAuthorize: true,
includeSchema: true,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
@ -338,6 +340,7 @@ describe('CLI call execution behavior', () => {
autoAuthorize: true,
includeSchema: false,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();

View File

@ -89,6 +89,16 @@ describe('daemon call fast path', () => {
);
});
it('leaves CloudBase calls on the config-aware runtime path', async () => {
mocks.createRuntime.mockRejectedValue(new Error('runtime path used'));
const { runCli } = await import('../src/cli.js');
await expect(runCli(['call', 'cloudbase.auth', '--output', 'json'])).rejects.toThrow('runtime path used');
expect(mocks.createRuntime).toHaveBeenCalled();
expect(mocks.daemonCallTool).not.toHaveBeenCalled();
});
it.each(['MCPORTER_RECORD', 'MCPORTER_REPLAY'] as const)(
'bypasses the daemon fast path while %s is active',
async (modeEnv) => {

View File

@ -34,12 +34,50 @@ async function ensureDistBuilt(): Promise<void> {
async function hasBun(): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
execFile('bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
execFile(process.env.BUN_BIN ?? 'bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
resolve(!error);
});
});
}
let bunCompileSupport: Promise<boolean> | undefined;
async function hasRunnableBunCompile(): Promise<boolean> {
bunCompileSupport ??= probeRunnableBunCompile();
return await bunCompileSupport;
}
async function probeRunnableBunCompile(): Promise<boolean> {
if (!(await hasBun())) {
return false;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bun-compile-probe-'));
const sourcePath = path.join(tempDir, 'probe.ts');
const binaryPath = path.join(tempDir, 'probe');
try {
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
const bun = process.env.BUN_BIN ?? 'bun';
const built = await new Promise<boolean>((resolve) => {
execFile(
bun,
['build', sourcePath, '--compile', '--outfile', binaryPath],
{ cwd: tempDir, env: process.env },
(error) => resolve(!error)
);
});
if (!built) {
return false;
}
return await new Promise<boolean>((resolve) => {
execFile(binaryPath, [], { cwd: tempDir, env: process.env }, (error, stdout) => {
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
});
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
async function ensureBunSupport(reason: string): Promise<boolean> {
if (process.platform === 'win32') {
console.warn(`bun not supported on Windows; skipping ${reason}.`);
@ -52,6 +90,17 @@ async function ensureBunSupport(reason: string): Promise<boolean> {
return true;
}
async function ensureRunnableBunCompile(reason: string): Promise<boolean> {
if (!(await ensureBunSupport(reason))) {
return false;
}
if (!(await hasRunnableBunCompile())) {
console.warn(`bun-compiled binaries cannot run on this runner; skipping ${reason}.`);
return false;
}
return true;
}
async function runGeneratedCli(
bundlePath: string,
args: string[],
@ -566,7 +615,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('runs "node dist/cli.js generate-cli --compile" when bun is available', async () => {
if (!(await ensureBunSupport('compile integration test'))) {
if (!(await ensureRunnableBunCompile('compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-'));
@ -616,7 +665,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('end-to-end: compiles a "bun" CLI and calls ping', async () => {
if (!(await ensureBunSupport('Bun CLI end-to-end test'))) {
if (!(await ensureRunnableBunCompile('Bun CLI end-to-end test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-bun-'));
@ -690,7 +739,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 30000);
it('runs "node dist/cli.js generate-cli --compile" using the Bun bundler by default', async () => {
if (!(await ensureBunSupport('Bun bundler compile integration test'))) {
if (!(await ensureRunnableBunCompile('Bun bundler compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-bun-'));
@ -739,7 +788,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
}, 20000);
it('accepts inline stdio commands (e.g., "npx -y chrome-devtools-mcp@latest") when compiling', async () => {
if (!(await ensureBunSupport('inline stdio compile integration test'))) {
if (!(await ensureRunnableBunCompile('inline stdio compile integration test'))) {
return;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-inline-stdio-'));
@ -884,7 +933,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
console.warn('set MCPORTER_STANDALONE_BINARY_TEST=1 to run standalone Bun release binary smoke');
return;
}
if (!(await ensureBunSupport('standalone Bun release binary smoke'))) {
if (!(await ensureRunnableBunCompile('standalone Bun release binary smoke'))) {
return;
}
await new Promise<void>((resolve, reject) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,12 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { __testProcessRequest, isDaemonResponding, metadataMatches } from '../src/daemon/host.js';
import {
__testProcessRequest,
cleanupDaemonArtifactsIfOwned,
isDaemonResponding,
metadataMatches,
} from '../src/daemon/host.js';
import type { DaemonRequest } from '../src/daemon/protocol.js';
import type { Runtime } from '../src/runtime.js';
@ -49,6 +54,7 @@ describe('daemon host request handling', () => {
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
args: {},
timeoutMs: undefined,
disableOAuth: false,
});
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
@ -61,6 +67,7 @@ describe('daemon host request handling', () => {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: false,
});
});
@ -78,6 +85,7 @@ describe('daemon host request handling', () => {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: false,
});
});
@ -95,6 +103,37 @@ describe('daemon host request handling', () => {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('forwards disableOAuth on daemon callTool and listTools requests', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'call',
method: 'callTool',
params: { server: 'oauth', tool: 'ping', disableOAuth: true },
});
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
args: {},
timeoutMs: undefined,
disableOAuth: true,
});
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', includeSchema: true, disableOAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: true,
});
});
@ -112,6 +151,7 @@ describe('daemon host request handling', () => {
includeSchema: undefined,
autoAuthorize: undefined,
allowCachedAuth: false,
disableOAuth: false,
});
});
});
@ -147,12 +187,6 @@ describeUnixSocket('isDaemonResponding', () => {
}
});
function statusServer(result: Record<string, unknown>): net.Server {
return net.createServer((socket) => {
socket.on('data', () => socket.end(JSON.stringify({ id: '1', ok: true, result })));
});
}
it('returns true when the socket answers status with a matching socket and live pid', async () => {
const p = socketPath();
await listen(statusServer({ pid: process.pid, socketPath: p }), p);
@ -217,6 +251,41 @@ describe('metadataMatches', () => {
});
});
describe('daemon artifact cleanup', () => {
let dir: string;
let metadataPath: string;
let socketPath: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cleanup-'));
metadataPath = path.join(dir, 'daemon.json');
socketPath = path.join(dir, 'daemon.sock');
await fs.writeFile(socketPath, 'socket', 'utf8');
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it('removes artifacts still owned by the stopping daemon', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath }), 'utf8');
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
await expect(fs.access(metadataPath)).rejects.toThrow();
await expect(fs.access(socketPath)).rejects.toThrow();
});
it('preserves artifacts replaced by a newer daemon', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9876, socketPath }), 'utf8');
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
await expect(fs.access(metadataPath)).resolves.toBeUndefined();
await expect(fs.access(socketPath)).resolves.toBeUndefined();
});
});
function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
return {
callTool: vi.fn().mockResolvedValue({ ok: true }),
@ -244,3 +313,9 @@ function createManagedServers(): Map<string, ServerDefinition> {
],
]);
}
function statusServer(result: Record<string, unknown>): net.Server {
return net.createServer((socket) => {
socket.on('data', () => socket.end(JSON.stringify({ id: '1', ok: true, result })));
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -203,9 +203,9 @@ describeGenerateCli('generateCli', () => {
});
await fs.mkdir(path.join(tmpDir, 'schema-cache'), { recursive: true });
const exec = await import('node:child_process');
const bunAvailable = await hasBun(exec);
const bunAvailable = await hasRunnableBunCompile(exec);
if (!bunAvailable) {
console.warn('bun is not available on this runner; skipping compilation checks.');
console.warn('bun-compiled binaries cannot run on this runner; skipping compilation checks.');
return;
}
await ensureDistBuilt();
@ -891,3 +891,38 @@ async function hasBun(exec: typeof import('node:child_process')) {
});
});
}
let bunCompileSupport: Promise<boolean> | undefined;
async function hasRunnableBunCompile(exec: typeof import('node:child_process')) {
bunCompileSupport ??= probeRunnableBunCompile(exec);
return await bunCompileSupport;
}
async function probeRunnableBunCompile(exec: typeof import('node:child_process')) {
if (!(await hasBun(exec))) {
return false;
}
const tempDir = await fs.mkdtemp(path.join(tmpDir, 'bun-compile-probe-'));
const sourcePath = path.join(tempDir, 'probe.ts');
const binaryPath = path.join(tempDir, 'probe');
try {
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
const bun = process.env.BUN_BIN ?? 'bun';
const built = await new Promise<boolean>((resolve) => {
exec.execFile(bun, ['build', sourcePath, '--compile', '--outfile', binaryPath], execOptions(), (error) =>
resolve(!error)
);
});
if (!built) {
return false;
}
return await new Promise<boolean>((resolve) => {
exec.execFile(binaryPath, [], execOptions(), (error, stdout) => {
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
});
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}

View File

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

View File

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

View File

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

View File

@ -44,6 +44,30 @@ describe('oauth persistence', () => {
await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('degrades corrupt credential caches to undefined but keeps corrupt OAuth state failing closed', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-corrupt-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
// Truncated / malformed credential files, e.g. an interrupted write.
await fs.writeFile(path.join(cacheDir, 'tokens.json'), '{ "access_token": "part');
await fs.writeFile(path.join(cacheDir, 'client.json'), 'not json at all');
await fs.writeFile(path.join(cacheDir, 'state.txt'), '"unterminated');
const persistence = await buildOAuthPersistence(mkDef('service', cacheDir));
// Corrupt credential caches must read as "no usable credentials" (degrade to
// re-auth), not surface a SyntaxError that crashes the connection.
expect(await persistence.readTokens()).toBeUndefined();
expect(await persistence.readClientInfo()).toBeUndefined();
// OAuth state must NOT silently degrade: returning undefined would skip the
// CSRF state check on callback (oauth.ts). It must fail closed.
await expect(persistence.readState()).rejects.toThrow(SyntaxError);
});
it('prefers explicit tokenCacheDir before vault when reading tokens', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
tempRoots.push(tmp);

View File

@ -148,10 +148,9 @@ describe('FileOAuthClientProvider session lifecycle', () => {
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('clearing stale client registration'));
});
it('closes the callback server when stale-client reads throw', async () => {
it('closes the callback server when stale-client reads have I/O errors', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
await fs.writeFile(path.join(tokenCacheDir, 'client.json'), '{not-valid-json', 'utf8');
const definition: ServerDefinition = {
name: 'test-oauth-read-failure',
description: 'Test OAuth server',
@ -165,6 +164,8 @@ describe('FileOAuthClientProvider session lifecycle', () => {
error: vi.fn(),
};
const readError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError);
const originalCreateServer = http.createServer.bind(http);
const createdServers: http.Server[] = [];
const createServerSpy = vi.spyOn(http, 'createServer').mockImplementation((...args) => {
@ -174,11 +175,12 @@ describe('FileOAuthClientProvider session lifecycle', () => {
});
try {
await expect(createOAuthSession(definition, logger)).rejects.toThrow(SyntaxError);
await expect(createOAuthSession(definition, logger)).rejects.toMatchObject({ code: 'EACCES' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(createdServers).toHaveLength(1);
expect(createdServers[0]?.listening).toBe(false);
} finally {
readFileSpy.mockRestore();
createServerSpy.mockRestore();
}
});

View File

@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
createClientContext: vi.fn(),
}));
vi.mock('../src/runtime/transport.js', () => ({
createClientContext: mocks.createClientContext,
}));
import type { ServerDefinition } from '../src/config.js';
import { createRuntime } from '../src/runtime.js';
type ClientContext = Awaited<ReturnType<Awaited<ReturnType<typeof createRuntime>>['connect']>>;
function fakeContext(
definition: ServerDefinition,
clientClose: ReturnType<typeof vi.fn> = vi.fn().mockResolvedValue(undefined)
): ClientContext {
return {
client: {
close: clientClose,
},
transport: {
close: vi.fn().mockResolvedValue(undefined),
},
definition,
oauthSession: undefined,
} as unknown as ClientContext;
}
describe('runtime cache policy', () => {
beforeEach(() => {
mocks.createClientContext.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('does not let stale OAuth promotion overwrite a replacement definition', async () => {
const initial: ServerDefinition = {
name: 'oauth',
command: { kind: 'http', url: new URL('https://old.example.com/mcp') },
};
let resolveConnection!: (context: ClientContext) => void;
let promote!: (definition: ServerDefinition) => void;
mocks.createClientContext.mockImplementation(
(
_definition: ServerDefinition,
_logger: unknown,
_clientInfo: unknown,
options: { onDefinitionPromoted?: (definition: ServerDefinition) => void }
) => {
promote = options.onDefinitionPromoted ?? (() => {});
return new Promise<ClientContext>((resolve) => {
resolveConnection = resolve;
});
}
);
const runtime = await createRuntime({ servers: [initial] });
const connecting = runtime.connect('oauth');
const expectation = expect(connecting).rejects.toThrow('superseded');
await vi.waitFor(() => expect(mocks.createClientContext).toHaveBeenCalled());
const replacement: ServerDefinition = {
name: 'oauth',
command: { kind: 'http', url: new URL('https://new.example.com/mcp') },
};
runtime.registerDefinition(replacement, { overwrite: true });
promote({ ...initial, auth: 'oauth' });
resolveConnection(fakeContext(initial));
await expectation;
expect(runtime.getDefinition('oauth')).toBe(replacement);
});
it('uses one replay client across auth posture changes', async () => {
vi.stubEnv('MCPORTER_REPLAY', 'cache-policy-test');
const definition: ServerDefinition = {
name: 'replay',
command: { kind: 'http', url: new URL('https://replay.example.com/mcp') },
};
const context = fakeContext(definition);
mocks.createClientContext.mockResolvedValue(context);
const runtime = await createRuntime({ servers: [definition] });
const first = await runtime.connect('replay');
const second = await runtime.connect('replay', {
allowCachedAuth: false,
disableOAuth: true,
});
expect(second).toBe(first);
expect(mocks.createClientContext).toHaveBeenCalledOnce();
await runtime.close();
});
it('keeps auth posture isolation for servers excluded by the replay filter', async () => {
vi.stubEnv('MCPORTER_REPLAY', 'cache-policy-test');
vi.stubEnv('MCPORTER_REPLAY_SERVER', 'other-server');
const definition: ServerDefinition = {
name: 'live',
command: { kind: 'http', url: new URL('https://live.example.com/mcp') },
};
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
mocks.createClientContext.mockImplementation((current: ServerDefinition) => {
const closeMock = vi.fn().mockResolvedValue(undefined);
const context = fakeContext(current, closeMock);
closeMocks.push(closeMock);
return Promise.resolve(context);
});
const runtime = await createRuntime({ servers: [definition] });
const first = await runtime.connect('live');
const second = await runtime.connect('live', {
allowCachedAuth: false,
});
expect(second).not.toBe(first);
expect(mocks.createClientContext).toHaveBeenCalledTimes(2);
expect(closeMocks[0]).toHaveBeenCalled();
await runtime.close();
});
});

157
tests/runtime-cache.test.ts Normal file
View File

@ -0,0 +1,157 @@
import { describe, expect, it, vi } from 'vitest';
import { createRuntime } from '../src/runtime.js';
type TestRuntime = Awaited<ReturnType<typeof createRuntime>>;
type ClientContext = Awaited<ReturnType<TestRuntime['connect']>>;
type CachedClientEntry = {
readonly server: string;
readonly promise: Promise<ClientContext>;
readonly allowCachedAuth: boolean | undefined;
readonly disableOAuth: boolean;
};
function fakeContext(instructions: string): ClientContext {
return {
client: {
close: vi.fn().mockResolvedValue(undefined),
getInstructions: vi.fn(() => instructions),
},
transport: { close: vi.fn().mockResolvedValue(undefined) },
definition: {
name: 'temp',
description: 'test',
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
oauthSession: undefined,
} as unknown as ClientContext;
}
describe('runtime cache entries', () => {
it('reads instructions from the active cached entry', async () => {
const runtime = await createRuntime({ servers: [] });
const older = fakeContext('older instructions');
const active = fakeContext('active instructions');
const internals = runtime as unknown as {
clients: Map<string, CachedClientEntry>;
activeClientKeys: Map<string, string>;
};
internals.clients.set('temp:older', {
server: 'temp',
promise: Promise.resolve(older),
allowCachedAuth: true,
disableOAuth: false,
});
internals.clients.set('temp:active', {
server: 'temp',
promise: Promise.resolve(active),
allowCachedAuth: true,
disableOAuth: true,
});
internals.activeClientKeys.set('temp', 'temp:active');
await expect(runtime.getInstructions?.('temp')).resolves.toBe('active instructions');
});
it('closes cached entries when replacing a server definition', async () => {
const runtime = await createRuntime({ servers: [] });
const context = fakeContext('old instructions');
const transport = context.transport as unknown as { close: ReturnType<typeof vi.fn> };
const internals = runtime as unknown as {
clients: Map<string, CachedClientEntry>;
contextCacheKeys: WeakMap<ClientContext, string>;
};
internals.clients.set('temp:old', {
server: 'temp',
promise: Promise.resolve(context),
allowCachedAuth: undefined,
disableOAuth: false,
});
internals.contextCacheKeys.set(context, 'temp:old');
runtime.registerDefinition(
{
name: 'temp',
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
{ overwrite: true }
);
await vi.waitFor(() => expect(transport.close).toHaveBeenCalled());
expect(internals.clients.has('temp:old')).toBe(false);
});
it('removes cached entries before awaiting shutdown', async () => {
const runtime = await createRuntime({ servers: [] });
let releaseClose!: () => void;
const clientClose = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = resolve;
})
);
const context = {
...fakeContext('closing instructions'),
client: {
close: clientClose,
getInstructions: vi.fn(() => 'closing instructions'),
},
} as unknown as ClientContext;
const internals = runtime as unknown as {
clients: Map<string, CachedClientEntry>;
activeClientKeys: Map<string, string>;
contextCacheKeys: WeakMap<ClientContext, string>;
};
internals.clients.set('temp:closing', {
server: 'temp',
promise: Promise.resolve(context),
allowCachedAuth: undefined,
disableOAuth: false,
});
internals.activeClientKeys.set('temp', 'temp:closing');
internals.contextCacheKeys.set(context, 'temp:closing');
const closing = runtime.close('temp');
expect(internals.clients.has('temp:closing')).toBe(false);
expect(internals.activeClientKeys.has('temp')).toBe(false);
await vi.waitFor(() => expect(clientClose).toHaveBeenCalled());
releaseClose();
await closing;
});
it('starts closing cached variants concurrently', async () => {
const runtime = await createRuntime({ servers: [] });
let resolvePending!: (context: ClientContext) => void;
const pending = new Promise<ClientContext>((resolve) => {
resolvePending = resolve;
});
const pendingContext = fakeContext('pending instructions');
const readyContext = fakeContext('ready instructions');
const readyTransport = readyContext.transport as unknown as { close: ReturnType<typeof vi.fn> };
const internals = runtime as unknown as {
clients: Map<string, CachedClientEntry>;
};
internals.clients.set('temp:pending', {
server: 'temp',
promise: pending,
allowCachedAuth: false,
disableOAuth: false,
});
internals.clients.set('temp:ready', {
server: 'temp',
promise: Promise.resolve(readyContext),
allowCachedAuth: true,
disableOAuth: true,
});
const closing = runtime.close('temp');
await vi.waitFor(() => expect(readyTransport.close).toHaveBeenCalled());
resolvePending(pendingContext);
await closing;
});
});

View File

@ -48,9 +48,10 @@ describe('runtime callTool timeouts', () => {
const runtime = await createRuntime({ servers: [] });
const callTool = vi.fn(() => new Promise(() => {}));
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const transport = { close: vi.fn().mockResolvedValue(undefined) };
const fakeContext = {
client: { callTool },
transport: { close: vi.fn().mockResolvedValue(undefined) },
transport,
definition: {
name: 'temp',
description: 'test',
@ -60,16 +61,42 @@ describe('runtime callTool timeouts', () => {
oauthSession: undefined,
} as unknown as ClientContext;
vi.spyOn(runtime, 'connect').mockResolvedValue(fakeContext);
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
'temp',
Promise.resolve(fakeContext)
);
const cachedPromise = Promise.resolve(fakeContext);
(
runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
}
).clients.set('temp:test', {
server: 'temp',
promise: cachedPromise,
allowCachedAuth: true,
disableOAuth: false,
});
(
runtime as unknown as {
contextCacheKeys: WeakMap<ClientContext, string>;
}
).contextCacheKeys.set(fakeContext, 'temp:test');
(
runtime as unknown as {
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
}
).contextCachePromises.set(fakeContext, cachedPromise);
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
const promise = runtime.callTool('temp', 'ping', { timeoutMs: 123 });
const expectation = expect(promise).rejects.toThrow('Timeout');
await vi.advanceTimersByTimeAsync(200);
await expectation;
expect(closeSpy).toHaveBeenCalledWith('temp');
expect(closeSpy).not.toHaveBeenCalled();
expect(transport.close).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,12 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
function throwConnectBoom(): never {
throw new Error('connect boom');
}
const mocks = vi.hoisted(() => {
const connectMock = vi.fn();
const listToolsMock = vi.fn();
@ -108,7 +115,7 @@ vi.mock('../src/oauth-persistence.js', () => ({
readCachedAccessToken: mocks.readCachedAccessTokenMock,
}));
import { createRuntime } from '../src/runtime.js';
import { callOnce, createRuntime } from '../src/runtime.js';
describe('mcporter composability', () => {
beforeEach(() => {
@ -228,6 +235,30 @@ describe('mcporter composability', () => {
expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-parent');
});
it('reuses stdio clients across auth-policy no-op differences', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'local',
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
],
});
try {
await runtime.connect('local');
await runtime.callTool('local', 'echo', {});
await runtime.connect('local', { disableOAuth: true });
await runtime.listTools('local', { autoAuthorize: false });
expect(mocks.stdioInstances).toHaveLength(1);
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
} finally {
await runtime.close();
}
});
it('overrides inherited env vars with server-specific values', async () => {
vi.stubEnv('MCPORTER_STDIO_TEST', 'parent');
const runtime = await createRuntime({
@ -271,6 +302,375 @@ describe('mcporter composability', () => {
}
});
it('preserves a disabled-OAuth cached connection through high-level helpers', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
try {
await runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: true });
await runtime.callTool('oauth', 'ping');
await runtime.listTools('oauth');
await runtime.listResources('oauth');
expect(mocks.streamableInstances).toHaveLength(1);
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
} finally {
await runtime.close();
}
});
it('reuses active cached-auth connections for resource helpers with unspecified auth policy', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
try {
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
await runtime.listTools('oauth');
await runtime.listResources('oauth');
expect(mocks.streamableInstances).toHaveLength(1);
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
} finally {
await runtime.close();
}
});
it('uses disableOAuth on cold callTool/listTools helper connections', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
auth: 'oauth' as const,
},
],
});
try {
await runtime.callTool('oauth', 'ping', { disableOAuth: true });
await runtime.listTools('oauth', { disableOAuth: true });
expect(mocks.streamableInstances).toHaveLength(1);
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
} finally {
await runtime.close();
}
});
it('preserves cached-auth opt out for disabled-OAuth helper calls', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
auth: 'oauth' as const,
},
],
});
try {
await runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: false });
await runtime.callTool('oauth', 'ping');
await runtime.listTools('oauth');
await runtime.listResources('oauth');
expect(mocks.streamableInstances).toHaveLength(1);
expect(mocks.readCachedAccessTokenMock).not.toHaveBeenCalled();
await runtime.connect('oauth', { disableOAuth: true });
expect(mocks.streamableInstances).toHaveLength(2);
} finally {
await runtime.close();
}
});
it('keeps separate cached transports for OAuth posture changes', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
try {
const disabled = await runtime.connect('oauth', { disableOAuth: true });
const disabledTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
const normal = await runtime.connect('oauth');
expect(normal).not.toBe(disabled);
expect(mocks.streamableInstances).toHaveLength(2);
expect(disabledTransport.close).not.toHaveBeenCalled();
await expect(runtime.connect('oauth', { disableOAuth: true })).resolves.toBe(disabled);
await runtime.callTool('oauth', 'ping');
expect(mocks.streamableInstances).toHaveLength(2);
} finally {
await runtime.close();
}
});
it('restores the previous active cached variant when a new variant fails to connect', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
try {
await runtime.connect('oauth');
await runtime.connect('oauth', { disableOAuth: true });
const internals = runtime as unknown as {
activeClientKeys: Map<string, string>;
clients: Map<
string,
{
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
};
const disabledKey = [...internals.clients.entries()].find(
([, cached]) => cached.disableOAuth && cached.allowCachedAuth === true
)?.[0];
mocks.connectMock.mockImplementationOnce(throwConnectBoom).mockImplementationOnce(throwConnectBoom);
await expect(runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: false })).rejects.toThrow(
'connect boom'
);
expect(internals.activeClientKeys.get('oauth')).toBe(disabledKey);
} finally {
await runtime.close();
}
});
it('serializes concurrent OAuth-capable HTTP variant setup', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
let releaseFirst!: () => void;
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
transport.start?.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseFirst = resolve;
})
);
});
try {
const first = runtime.connect('oauth', { allowCachedAuth: false });
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
const second = runtime.connect('oauth', { allowCachedAuth: true });
await Promise.resolve();
expect(mocks.streamableInstances).toHaveLength(1);
releaseFirst();
await first;
await second;
expect(mocks.streamableInstances).toHaveLength(2);
} finally {
await runtime.close();
}
});
it('does not create a new OAuth-capable variant after close interrupts retirement', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
await runtime.connect('oauth', { allowCachedAuth: false });
let releaseClose!: () => void;
const firstClient = mocks.clientInstances[0] as { close: () => Promise<void> };
firstClient.close = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = resolve;
})
);
const replacement = runtime.connect('oauth', { allowCachedAuth: true });
const replacementExpectation = expect(replacement).rejects.toThrow('superseded');
await vi.waitFor(() => expect(firstClient.close).toHaveBeenCalled());
const closing = runtime.close('oauth');
releaseClose();
await Promise.all([replacementExpectation, closing]);
expect(mocks.streamableInstances).toHaveLength(1);
});
it('releases serialized setup after conflicting-entry retirement fails', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const rejected = Promise.reject(new Error('retire boom')) as Promise<ClientContext>;
void rejected.catch(() => {});
(
runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
contextPromise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
}
).clients.set('oauth:conflict', {
server: 'oauth',
promise: rejected,
contextPromise: rejected,
allowCachedAuth: false,
disableOAuth: false,
});
await expect(runtime.connect('oauth', { allowCachedAuth: true })).rejects.toThrow('retire boom');
await expect(runtime.connect('oauth', { allowCachedAuth: true })).resolves.toBeDefined();
await runtime.close();
});
it('cancels queued OAuth-capable setup when the server closes', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
},
],
});
let releaseFirst!: () => void;
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
transport.start?.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseFirst = resolve;
})
);
});
const first = runtime.connect('oauth', { allowCachedAuth: false });
const firstExpectation = expect(first).rejects.toThrow('superseded');
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
const second = runtime.connect('oauth', { allowCachedAuth: true });
const secondExpectation = expect(second).rejects.toThrow('superseded');
await Promise.resolve();
const closing = runtime.close('oauth');
releaseFirst();
await Promise.all([firstExpectation, secondExpectation, closing]);
expect(mocks.streamableInstances).toHaveLength(1);
});
it('rejects an in-flight connection when its definition is replaced', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://old.example.com/mcp') },
},
],
});
let releaseConnect!: () => void;
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
transport.start?.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseConnect = resolve;
})
);
});
const connecting = runtime.connect('oauth');
const waiting = runtime.connect('oauth');
const expectations = Promise.all([
expect(connecting).rejects.toThrow('superseded'),
expect(waiting).rejects.toThrow('superseded'),
]);
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
runtime.registerDefinition(
{
name: 'oauth',
command: { kind: 'http' as const, url: new URL('https://new.example.com/mcp') },
},
{ overwrite: true }
);
releaseConnect();
await expectations;
const oldTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
await vi.waitFor(() => expect(oldTransport.close).toHaveBeenCalled());
});
it('forwards disableOAuth through callOnce', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-call-once-'));
const configPath = path.join(tempDir, 'mcporter.json');
await fs.writeFile(
configPath,
JSON.stringify({
mcpServers: {
oauth: {
url: 'https://oauth.example.com/mcp',
auth: 'oauth',
},
},
}),
'utf8'
);
try {
await callOnce({
server: 'oauth',
toolName: 'ping',
args: { ok: true },
configPath,
disableOAuth: true,
});
expect(mocks.callToolMock).toHaveBeenCalledWith({
name: 'ping',
arguments: { ok: true },
});
const streamableTransport = mocks.streamableInstances[0] as {
options?: { authProvider?: unknown };
};
expect(streamableTransport.options?.authProvider).toBeUndefined();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('reconnects when callTool needs cached auth after an uncached connection', async () => {
const runtime = await createRuntime({
servers: [
@ -284,11 +684,13 @@ describe('mcporter composability', () => {
try {
await runtime.listTools('oauth', { allowCachedAuth: false });
expect(mocks.streamableInstances).toHaveLength(1);
const firstTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
await runtime.callTool('oauth', 'ping');
expect(mocks.streamableInstances).toHaveLength(2);
expect(firstTransport.close).toHaveBeenCalled();
const streamableTransport = mocks.streamableInstances[1] as {
options?: { requestInit?: { headers?: Record<string, string> } };
};

View File

@ -11,11 +11,12 @@ describe('runtime connection resets', () => {
const runtime = await createRuntime({ servers: [] });
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
const transport = { close: vi.fn().mockResolvedValue(undefined) };
const context = {
client: {
callTool: vi.fn().mockRejectedValue(rejected),
},
transport: { close: vi.fn().mockResolvedValue(undefined) },
transport,
definition: {
name: 'temp',
description: 'test',
@ -25,25 +26,53 @@ describe('runtime connection resets', () => {
oauthSession: undefined,
} as unknown as ClientContext;
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
'temp',
Promise.resolve(context)
);
const promise = Promise.resolve(context);
(
runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
}
).clients.set('temp:test', {
server: 'temp',
promise,
allowCachedAuth: true,
disableOAuth: false,
});
(
runtime as unknown as {
contextCacheKeys: WeakMap<ClientContext, string>;
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
}
).contextCacheKeys.set(context, 'temp:test');
(
runtime as unknown as {
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
}
).contextCachePromises.set(context, promise);
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
expect(closeSpy).toHaveBeenCalledWith('temp');
expect(closeSpy).not.toHaveBeenCalled();
expect(transport.close).toHaveBeenCalled();
});
it('keeps the connection open for user-facing InvalidParams errors', async () => {
const runtime = await createRuntime({ servers: [] });
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const rejected = new McpError(ErrorCode.InvalidParams, 'Tool help not found');
const transport = { close: vi.fn().mockResolvedValue(undefined) };
const context = {
client: {
callTool: vi.fn().mockRejectedValue(rejected),
},
transport: { close: vi.fn().mockResolvedValue(undefined) },
transport,
definition: {
name: 'temp',
description: 'test',
@ -53,13 +82,222 @@ describe('runtime connection resets', () => {
oauthSession: undefined,
} as unknown as ClientContext;
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
'temp',
Promise.resolve(context)
);
const promise = Promise.resolve(context);
(
runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
}
).clients.set('temp:test', {
server: 'temp',
promise,
allowCachedAuth: true,
disableOAuth: false,
});
(
runtime as unknown as {
contextCacheKeys: WeakMap<ClientContext, string>;
}
).contextCacheKeys.set(context, 'temp:test');
(
runtime as unknown as {
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
}
).contextCachePromises.set(context, promise);
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
await expect(runtime.callTool('temp', 'help')).rejects.toThrow('Tool help not found');
expect(closeSpy).not.toHaveBeenCalled();
expect(transport.close).not.toHaveBeenCalled();
});
it('does not wait for unrelated cached connections when resetting a failed context', async () => {
const runtime = await createRuntime({ servers: [] });
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
const transport = { close: vi.fn().mockResolvedValue(undefined) };
const context = {
client: {
callTool: vi.fn().mockRejectedValue(rejected),
},
transport,
definition: {
name: 'temp',
description: 'test',
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
oauthSession: undefined,
} as unknown as ClientContext;
const unresolved = new Promise<ClientContext>(() => {});
const failedPromise = Promise.resolve(context);
const internals = runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
contextCacheKeys: WeakMap<ClientContext, string>;
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
};
internals.clients.set('temp:unrelated', {
server: 'temp',
promise: unresolved,
allowCachedAuth: true,
disableOAuth: false,
});
internals.clients.set('temp:failed', {
server: 'temp',
promise: failedPromise,
allowCachedAuth: true,
disableOAuth: true,
});
internals.contextCacheKeys.set(context, 'temp:failed');
internals.contextCachePromises.set(context, failedPromise);
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
expect(transport.close).toHaveBeenCalled();
expect(internals.clients.has('temp:failed')).toBe(false);
expect(internals.clients.has('temp:unrelated')).toBe(true);
});
it('leaves cached entries alone when an uncached list operation fails', async () => {
const runtime = await createRuntime({ servers: [] });
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
const cachedTransport = { close: vi.fn().mockResolvedValue(undefined) };
const uncachedTransport = { close: vi.fn().mockResolvedValue(undefined) };
const cachedContext = {
client: {},
transport: cachedTransport,
definition: {
name: 'temp',
description: 'test',
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
oauthSession: undefined,
} as unknown as ClientContext;
const uncachedContext = {
client: {
listTools: vi.fn().mockRejectedValue(rejected),
},
transport: uncachedTransport,
definition: {
name: 'temp',
description: 'test',
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
oauthSession: undefined,
} as unknown as ClientContext;
const internals = runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
contextCacheKeys: WeakMap<ClientContext, string>;
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
};
internals.clients.set('temp:cached', {
server: 'temp',
promise: Promise.resolve(cachedContext),
allowCachedAuth: true,
disableOAuth: false,
});
internals.contextCacheKeys.set(cachedContext, 'temp:cached');
vi.spyOn(runtime, 'connect').mockResolvedValue(uncachedContext);
await expect(runtime.listTools('temp', { autoAuthorize: false })).rejects.toThrow('Connection closed');
expect(uncachedTransport.close).toHaveBeenCalled();
expect(cachedTransport.close).not.toHaveBeenCalled();
expect(internals.clients.has('temp:cached')).toBe(true);
});
it('does not evict a replacement while closing a failed stdio context', async () => {
const runtime = await createRuntime({ servers: [] });
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
let releaseClose!: () => void;
const transport = {
close: vi.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = resolve;
})
),
};
const context = {
client: {
close: vi.fn().mockResolvedValue(undefined),
callTool: vi.fn().mockRejectedValue(rejected),
},
transport,
definition: {
name: 'temp',
description: 'test',
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
oauthSession: undefined,
} as unknown as ClientContext;
const promise = Promise.resolve(context);
const internals = runtime as unknown as {
clients: Map<
string,
{
server: string;
promise: Promise<ClientContext>;
allowCachedAuth: boolean | undefined;
disableOAuth: boolean;
}
>;
contextCacheKeys: WeakMap<ClientContext, string>;
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
};
internals.clients.set('temp:stdio', {
server: 'temp',
promise,
allowCachedAuth: undefined,
disableOAuth: false,
});
internals.contextCacheKeys.set(context, 'temp:stdio');
internals.contextCachePromises.set(context, promise);
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
const call = runtime.callTool('temp', 'list_pages');
const expectation = expect(call).rejects.toThrow('Connection closed');
await vi.waitFor(() => expect(transport.close).toHaveBeenCalled());
const replacement = Promise.resolve({
...context,
transport: { close: vi.fn().mockResolvedValue(undefined) },
} as unknown as ClientContext);
internals.clients.set('temp:stdio', {
server: 'temp',
promise: replacement,
allowCachedAuth: true,
disableOAuth: true,
});
releaseClose();
await expectation;
expect(internals.clients.get('temp:stdio')?.promise).toBe(replacement);
});
});

View File

@ -133,4 +133,157 @@ describe('runtime integration', () => {
await runtime.close('integration');
});
it('reuses cached connection when disableOAuth: true is passed', async () => {
// Headless-daemon use case: the caller wants OAuth suppression
// (no browser launches) but still expects connection caching so
// every callTool doesn't spawn a fresh transport. Previously the
// only way to suppress OAuth was `maxOAuthAttempts: 0`, which
// forced `useCache = false` as a side effect — see the connect()
// gate. `disableOAuth: true` preserves caching.
const runtime = await createRuntime({
servers: [
{
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: baseUrl },
},
],
});
const first = await runtime.connect('integration', { disableOAuth: true });
const second = await runtime.connect('integration', { disableOAuth: true });
expect(second).toBe(first);
// close() reaps the cached client.
await runtime.close('integration');
const reopened = await runtime.connect('integration', { disableOAuth: true });
expect(reopened).not.toBe(first);
await runtime.close('integration');
});
it('treats disableOAuth: false like omitted for cache identity', async () => {
const runtime = await createRuntime({
servers: [
{
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: baseUrl },
},
],
});
const explicitFalse = await runtime.connect('integration', { disableOAuth: false });
const omitted = await runtime.connect('integration', {});
expect(omitted).toBe(explicitFalse);
await runtime.close('integration');
});
it('maxOAuthAttempts: 0 still bypasses the cache (existing contract preserved)', async () => {
// Regression guard: callers passing maxOAuthAttempts: 0 today get
// a fresh client per call. That contract is unchanged — only the
// new `disableOAuth` flag enables caching with OAuth suppression.
const runtime = await createRuntime({
servers: [
{
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: baseUrl },
},
],
});
const first = await runtime.connect('integration', { maxOAuthAttempts: 0 });
const second = await runtime.connect('integration', { maxOAuthAttempts: 0 });
expect(second).not.toBe(first);
await runtime.close('integration');
});
it('keeps separate cached clients when disableOAuth flag changes', async () => {
// Connections established with disableOAuth: true vs without are
// semantically different (the former cannot inherit an OAuth
// session that may refresh into a flow). The cache slot must not
// be shared across that boundary.
const runtime = await createRuntime({
servers: [
{
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: baseUrl },
},
],
});
const cached = await runtime.connect('integration', { disableOAuth: true });
const withFlowAllowed = await runtime.connect('integration', {});
expect(withFlowAllowed).not.toBe(cached);
const cachedAgain = await runtime.connect('integration', { disableOAuth: true });
expect(cachedAgain).toBe(cached);
await runtime.close('integration');
});
it('preserves the cached client across connect(disableOAuth:true) → callTool() (no implicit eviction)', async () => {
// Regression for the PR-198 review note (Codex r3366238654): the
// documented headless setup is `await runtime.connect(server, {
// disableOAuth: true })`. That call stored the cache slot with
// `allowCachedAuth: undefined`. The subsequent internal
// `callTool()` path forces `allowCachedAuth: true`, and the
// cache-match check (existing.allowCachedAuth === options.allowCachedAuth
// || options.allowCachedAuth === undefined) treated the two as
// structurally different — every first callTool evicted and
// reopened the transport. Defeats the pooling guarantee for the
// common pre-connect path.
const runtime = await createRuntime({
servers: [
{
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: baseUrl },
},
],
});
const initial = await runtime.connect('integration', { disableOAuth: true });
const callResult = (await runtime.callTool('integration', 'add', {
args: { a: 1, b: 2 },
})) as { structuredContent?: { result: number } };
expect(callResult.structuredContent?.result).toBe(3);
// After callTool, the cache slot should still hold the same
// ClientContext established by the prior connect() — no eviction,
// no extra transport spawned.
const afterCall = await runtime.connect('integration', { disableOAuth: true });
expect(afterCall).toBe(initial);
await runtime.close('integration');
});
it('preserves the cached client across connect(disableOAuth:true) → listTools() (no implicit eviction)', async () => {
// Same shape as the callTool regression: listTools also forces
// `allowCachedAuth: options.allowCachedAuth ?? true` internally,
// so the pre-connected slot was being evicted on first listTools.
const runtime = await createRuntime({
servers: [
{
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: baseUrl },
},
],
});
const initial = await runtime.connect('integration', { disableOAuth: true });
const tools = await runtime.listTools('integration');
expect(tools.some((tool) => tool.name === 'add')).toBe(true);
const afterList = await runtime.connect('integration', { disableOAuth: true });
expect(afterList).toBe(initial);
await runtime.close('integration');
});
});

View File

@ -352,6 +352,80 @@ describe('createClientContext (HTTP)', () => {
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
});
it('does not create OAuth sessions for OAuth HTTP servers when disableOAuth is true', async () => {
const definition = stubOAuthHttpDefinition('https://example.com/secure');
mocks.connectWithAuth.mockImplementationOnce(async (_client, transport, session) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
expect(session).toBeUndefined();
return transport;
});
const context = await createClientContext(definition, logger, clientInfo, {
disableOAuth: true,
allowCachedAuth: true,
});
expect(context.definition.auth).toBe('oauth');
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(1);
});
it('does not promote ad-hoc HTTP servers after Streamable 401 when disableOAuth is true', async () => {
const definition = stubHttpDefinition('https://example.com/secure');
mocks.connectWithAuth
.mockImplementationOnce(async (_client, transport, session) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
expect(session).toBeUndefined();
throw new Error('SSE error: Non-200 status code (401)');
})
.mockImplementationOnce(async (_client, transport, session) => {
expect(transport).toBeInstanceOf(SSEClientTransport);
expect(session).toBeUndefined();
return transport;
});
const { promotedDefinitions, onDefinitionPromoted } = createPromotionRecorder();
const context = await createClientContext(definition, logger, clientInfo, {
disableOAuth: true,
onDefinitionPromoted,
});
expect(context.definition.auth).toBeUndefined();
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
expect(promotedDefinitions).toEqual([]);
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
});
it('does not promote ad-hoc HTTP servers after SSE 401 when disableOAuth is true', async () => {
const definition = stubHttpDefinition('https://example.com/sse-auth');
mocks.connectWithAuth
.mockImplementationOnce(async (_client, transport, session) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
expect(session).toBeUndefined();
throw new Error('HTTP error 405: Method Not Allowed');
})
.mockImplementationOnce(async (_client, transport, session) => {
expect(transport).toBeInstanceOf(SSEClientTransport);
expect(session).toBeUndefined();
throw new Error('SSE error: Non-200 status code (401)');
});
const { promotedDefinitions, onDefinitionPromoted } = createPromotionRecorder();
await expect(
createClientContext(definition, logger, clientInfo, {
disableOAuth: true,
onDefinitionPromoted,
})
).rejects.toThrow('Non-200 status code (401)');
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
expect(promotedDefinitions).toEqual([]);
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
});
it('promotes ad-hoc HTTP servers after generic 401 errors from Streamable HTTP', async () => {
const definition = stubHttpDefinition('https://example.com/secure');

View File

@ -1,3 +1,4 @@
import http from 'node:http';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
@ -295,4 +296,117 @@ describe('mcporter serve bridge', () => {
});
}
});
it('exposes a single server unprefixed in bare mode', async () => {
const runtime = {
listTools: vi
.fn()
.mockImplementation(async (server: string) => [
{ name: 'ping', description: `${server} ping`, inputSchema: { type: 'object' } },
]),
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong' }] }),
};
const bridge = createBridgeServer({ runtime, definitions, servers: ['alpha'], bare: true });
const client = new Client({ name: 'test-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
expect(tools.tools).toEqual([expect.objectContaining({ name: 'ping', description: 'alpha ping' })]);
await expect(client.callTool({ name: 'ping', arguments: { value: 1 } })).resolves.toEqual({
content: [{ type: 'text', text: 'pong' }],
});
expect(runtime.callTool).toHaveBeenCalledWith('alpha', 'ping', { args: { value: 1 } });
await client.close();
await bridge.close();
});
it('rejects bare mode unless exactly one server is served', () => {
const runtime = { listTools: vi.fn(), callTool: vi.fn() };
expect(() => createBridgeServer({ runtime, definitions, bare: true })).toThrow(
'Bare serve mode requires exactly one served server.'
);
});
it('serves a single server unprefixed over /mcp/<server> without changing aggregate /mcp', async () => {
const runtime = {
listTools: vi.fn().mockResolvedValue([{ name: 'ping', inputSchema: { type: 'object' } }]),
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong-http' }] }),
};
const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
const address = httpServer.address();
if (!address || typeof address !== 'object') {
throw new Error('Expected test HTTP server to listen on a TCP port.');
}
const perServerClient = new Client({ name: 'test-http-client', version: '1.0.0' });
const perServerTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp/alpha`));
const aggregateClient = new Client({ name: 'test-http-aggregate-client', version: '1.0.0' });
const aggregateTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
try {
await perServerClient.connect(perServerTransport);
const perServerTools = await perServerClient.listTools();
expect(perServerTools.tools.map((tool) => tool.name)).toEqual(['ping']);
await expect(perServerClient.callTool({ name: 'ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'pong-http' }],
});
await aggregateClient.connect(aggregateTransport);
const aggregateTools = await aggregateClient.listTools();
expect(aggregateTools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
await expect(aggregateClient.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'pong-http' }],
});
const unknown = await fetch(`http://127.0.0.1:${address.port}/mcp/nope`);
expect(unknown.status).toBe(404);
expect(unknown.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(await unknown.text()).toBe("Unknown server 'nope'");
} finally {
await perServerClient.close().catch(() => {});
await aggregateClient.close().catch(() => {});
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('returns 400 for a malformed percent-encoded server path', async () => {
const runtime = { listTools: vi.fn(), callTool: vi.fn() };
const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
const address = httpServer.address();
if (!address || typeof address !== 'object') {
throw new Error('Expected test HTTP server to listen on a TCP port.');
}
try {
const status = await new Promise<number>((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port: address.port, path: '/mcp/%E0%A4%A', method: 'GET' },
(res) => {
res.resume();
resolve(res.statusCode ?? 0);
}
);
req.on('error', reject);
req.end();
});
expect(status).toBe(400);
} finally {
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
});

View File

@ -333,4 +333,273 @@ describe('createServerProxy', () => {
tailLog: true,
});
});
it('threads disableOAuth through schema discovery so proxy.tool({disableOAuth:true}) cannot trigger OAuth during metadata fetch', async () => {
// Regression for the PR-198 reviewer note: the proxy fired
// `runtime.listTools(server, { includeSchema: true })` for schema
// discovery BEFORE parsing the caller's options. On an OAuth
// server with no cached schema, that pre-call could start an
// interactive OAuth flow even when the eventual tool call had
// `disableOAuth: true`. Fix: the proxy must extract disableOAuth
// up front and pass it to listTools so the no-OAuth contract
// covers the whole proxy interaction.
const runtime = createMockRuntime({
'some-tool': {
type: 'object',
properties: {
foo: { type: 'string' },
disableOAuth: { type: 'boolean' },
},
required: ['foo'],
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.someTool as (args: unknown, options: unknown) => Promise<CallResult>;
await fn({ foo: 'bar' }, { disableOAuth: true });
// The schema-fetch listTools call must carry disableOAuth: true.
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
includeSchema: true,
disableOAuth: true,
});
// And the eventual tool call must too — already covered by the
// existing KNOWN_OPTION_KEYS handling, asserted here so both
// halves of the contract are locked together.
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
args: { foo: 'bar' },
disableOAuth: true,
});
});
it('detects disableOAuth metadata options before later argument objects', async () => {
const runtime = createMockRuntime({
'some-tool': {
type: 'object',
properties: {
foo: { type: 'string' },
},
required: ['foo'],
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.someTool as (options: unknown, args: unknown) => Promise<CallResult>;
await fn({ disableOAuth: true }, { foo: 'bar' });
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
includeSchema: true,
disableOAuth: true,
});
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
args: { foo: 'bar' },
disableOAuth: true,
});
});
it('preserves schema-owned disableOAuth fields after metadata discovery', async () => {
const runtime = createMockRuntime({
'some-tool': {
type: 'object',
properties: {
disableOAuth: { type: 'boolean' },
},
required: ['disableOAuth'],
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.someTool as (args: unknown) => Promise<CallResult>;
await fn({ disableOAuth: true });
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
includeSchema: true,
});
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
args: { disableOAuth: true },
});
});
it('preserves schema-owned disableOAuth fields beside proxy options', async () => {
const runtime = createMockRuntime({
'some-tool': {
type: 'object',
properties: {
disableOAuth: { type: 'boolean' },
},
required: ['disableOAuth'],
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.someTool as (args: unknown, options: unknown) => Promise<CallResult>;
await fn({ disableOAuth: true }, { tailLog: true });
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
includeSchema: true,
autoAuthorize: false,
});
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
args: { disableOAuth: true },
tailLog: true,
});
});
it('preserves schema-owned disableOAuth fields beside positional arguments', async () => {
const runtime = createMockRuntime({
'some-tool': {
type: 'object',
properties: {
value: { type: 'string' },
disableOAuth: { type: 'boolean' },
},
required: ['value', 'disableOAuth'],
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.someTool as (value: string, args: unknown) => Promise<CallResult>;
await fn('x', { disableOAuth: true });
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
args: {
value: 'x',
disableOAuth: true,
},
});
});
it('does not override active OAuth posture when schema discovery is cached', async () => {
const schema = {
type: 'object',
properties: {
value: { type: 'string' },
disableOAuth: { type: 'boolean' },
},
required: ['value', 'disableOAuth'],
};
const runtime = createMockRuntime({ 'some-tool': schema });
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock', {
initialSchemas: { 'some-tool': schema },
}) as Record<string, unknown>;
const fn = proxy.someTool as (value: string, args: unknown) => Promise<CallResult>;
await fn('x', { disableOAuth: true });
expect(runtime.listTools).not.toHaveBeenCalled();
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
args: {
value: 'x',
disableOAuth: true,
},
});
});
it('suppresses schema discovery for split proxy option bags', async () => {
const runtime = createMockRuntime({
ping: {
type: 'object',
properties: {},
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.ping as (options: unknown, additionalOptions: unknown) => Promise<CallResult>;
await fn({ disableOAuth: true }, { tailLog: true });
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
includeSchema: true,
autoAuthorize: false,
});
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'ping', {
disableOAuth: true,
tailLog: true,
});
});
it('supports explicit args envelopes for option-only disableOAuth metadata discovery', async () => {
const runtime = createMockRuntime({
ping: {
type: 'object',
properties: {},
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.ping as (options: unknown) => Promise<CallResult>;
await fn({ args: {}, disableOAuth: true });
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
includeSchema: true,
disableOAuth: true,
});
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'ping', {
args: {},
disableOAuth: true,
});
});
it('does not join an unsuppressed in-flight schema fetch for a disabled-OAuth call', async () => {
const tools: ServerToolInfo[] = [
{
name: 'ping',
inputSchema: {
type: 'object',
properties: {},
},
},
];
let resolveOrdinary!: (tools: ServerToolInfo[]) => void;
const listTools = vi.fn((_server: string, options?: { disableOAuth?: boolean }) => {
if (options?.disableOAuth === true) {
return Promise.resolve(tools);
}
return new Promise<ServerToolInfo[]>((resolve) => {
resolveOrdinary = resolve;
});
});
const runtime = {
callTool: vi.fn(async (_, __, options) => options),
listTools,
getDefinition: vi.fn(() => {
throw new Error('no persistent schema cache');
}),
};
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock', {
cacheSchemas: false,
}) as Record<string, unknown>;
const fn = proxy.ping as (options?: unknown) => Promise<CallResult>;
const ordinary = fn();
await vi.waitFor(() => expect(listTools).toHaveBeenCalledTimes(1));
const suppressed = fn({ args: {}, disableOAuth: true });
await expect(suppressed).resolves.toBeDefined();
expect(listTools).toHaveBeenNthCalledWith(2, 'mock', {
includeSchema: true,
disableOAuth: true,
});
resolveOrdinary(tools);
await ordinary;
});
it('preserves schema-owned fields that share proxy option names', async () => {
const runtime = createMockRuntime({
wait: {
type: 'object',
properties: {
timeout: { type: 'number' },
},
required: ['timeout'],
},
});
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
const fn = proxy.wait as (args: unknown) => Promise<CallResult>;
await fn({ timeout: 1000 });
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'wait', {
args: { timeout: 1000 },
});
});
});

View File

@ -117,4 +117,20 @@ describe('stdio MCP servers (filesystem + memory)', () => {
},
20000
);
memoryTest(
'passes multiline @path argument values unchanged to a stdio MCP server',
async () => {
const payloadPath = path.join(tempDir, 'multiline.txt');
const payload = 'first line\nsecond line\n';
await fs.writeFile(payloadPath, payload, 'utf8');
const callResult = await runCli(
['call', 'memory-test.echo_text', '--output', 'json', `text=@${payloadPath}`],
configPath
);
expect(callResult.stderr).toBe('');
expect(JSON.parse(callResult.stdout)).toMatchObject({ text: payload });
},
20000
);
});

View File

@ -1,6 +1,7 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { templateTestHelpers } from '../src/cli/generate/template.js';
import { renderTemplate, templateTestHelpers } from '../src/cli/generate/template.js';
import type { CliArtifactMetadata } from '../src/cli-metadata.js';
import type { ServerDefinition } from '../src/config.js';
const { computeRelativeStdioCwd } = templateTestHelpers;
@ -49,3 +50,103 @@ describe('computeRelativeStdioCwd', () => {
expect(computeRelativeStdioCwd(stdioDef({ cwd: 'relative-dir' }), outputPath)).toBe(expected);
});
});
describe('renderTemplate', () => {
it('rejects sanitized command name collisions before emitting a broken CLI', () => {
expect(() =>
renderTemplate({
runtimeKind: 'node',
timeoutMs: 30_000,
definition: stdioDef(),
serverName: 'demo',
generator: { name: 'mcporter', version: 'test' },
metadata: metadataFor('demo'),
tools: [
{
tool: { name: 'foo/bar', inputSchema: undefined, outputSchema: undefined },
methodName: 'fooSlash',
options: [],
},
{
tool: { name: 'foo_bar', inputSchema: undefined, outputSchema: undefined },
methodName: 'fooUnderscore',
options: [],
},
],
})
).toThrow(/Generated command name collision 'foo-bar'/);
});
it('emits strict numeric parsing and empty required-string validation', () => {
const source = renderTemplate({
runtimeKind: 'node',
timeoutMs: 30_000,
definition: stdioDef(),
serverName: 'demo',
generator: { name: 'mcporter', version: 'test' },
metadata: metadataFor('demo'),
tools: [
{
tool: {
name: 'sum',
inputSchema: {
type: 'object',
properties: {
count: { type: 'number' },
coords: { type: 'array', items: { type: 'number' } },
name: { type: 'string' },
},
required: ['name'],
},
outputSchema: undefined,
},
methodName: 'sum',
options: [
{
property: 'count',
cliName: 'count',
required: false,
type: 'number',
placeholder: '<count:number>',
},
{
property: 'coords',
cliName: 'coords',
required: false,
type: 'array',
arrayItemType: 'number',
placeholder: '<coords:value1,value2>',
},
{
property: 'name',
cliName: 'name',
required: true,
type: 'string',
placeholder: '<name>',
},
],
},
],
});
expect(source).toContain('parseFiniteNumber');
expect(source).not.toContain('parseFloat');
expect(source).toContain("typeof entry.value === 'string' && entry.value.trim() === ''");
});
});
function metadataFor(serverName: string): CliArtifactMetadata {
return {
schemaVersion: 1,
generatedAt: '1970-01-01T00:00:00.000Z',
generator: { name: 'mcporter', version: 'test' },
server: {
name: serverName,
definition: {
name: serverName,
command: { kind: 'stdio' as const, command: 'node', args: [], cwd: process.cwd() },
},
},
artifact: { path: '', kind: 'template' as const },
invocation: { runtime: 'node' as const, timeoutMs: 30_000, minify: false },
};
}

View File

@ -40,6 +40,14 @@ describe('loadToolMetadata', () => {
expect(listTools).toHaveBeenCalledTimes(2);
});
it('differentiates cache entries by disableOAuth flag', async () => {
const listTools = vi.fn(async () => [demoTool]);
const runtime = createRuntimeStub(listTools);
await loadToolMetadata(runtime, 'integration', { includeSchema: true });
await loadToolMetadata(runtime, 'integration', { includeSchema: true, disableOAuth: true });
expect(listTools).toHaveBeenCalledTimes(2);
});
it('passes cached OAuth preference to the runtime', async () => {
const listTools = vi.fn(async () => [demoTool]);
const runtime = createRuntimeStub(listTools);
@ -47,11 +55,13 @@ describe('loadToolMetadata', () => {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: true,
});
expect(listTools).toHaveBeenCalledWith('integration', {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: true,
});
});
});