* 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>
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.
Stop direct and foreground daemon starts from orphaning an already-running daemon by validating live daemon metadata, repairing missing metadata, and replacing stale-definition daemons only after they exit.
Proof:
- ./runner pnpm exec vitest run tests/daemon-host.test.ts tests/daemon.integration.test.ts
- ./runner pnpm check
- ./runner pnpm test
- autoreview --mode branch --base origin/main
- PR CI passed on macOS, Ubuntu, Windows, and Socket checks
Co-authored-by: zm2231 <25645999+zm2231@users.noreply.github.com>
* feat(record): capture MCP call streams to NDJSON and replay deterministically
mcporter record <session> wraps the runtime transport and appends every
JSON-RPC request, response, and notification to a per-session NDJSON file
under ~/.mcporter/recordings/. mcporter replay <session> reconstructs an
in-memory transport from the recording and matches requests by method +
deep-equal params, returning the recorded response without contacting
the live server.
Use cases:
- Reproduce MCP-backed agent bugs offline (no live Linear quota, no
Vercel API rate limits)
- Build test fixtures from real call sequences
- Share a session for a postmortem without sharing credentials
The format is plain JSON-RPC over NDJSON with a small _meta field
(direction, server, timestamp). No proprietary blob. Env-var passthrough
(MCPORTER_RECORD=<name>, MCPORTER_REPLAY=<name>) lets the existing
runtime constructor wrap any transport when set.
* fix(replay): attach cause to wrapped errors to satisfy preserve-caught-error lint
* fix(replay): rewrite response ids during replay
* fix(replay): harden record replay modes
Clear conflicting record/replay env vars when spawning wrapped commands, force those commands off the daemon fast path, truncate each recording file at session start, and fail replay close when recorded requests remain unused.
* fix(cli): preserve wrapped command flags
Stop global flag extraction at -- so record/replay wrappers do not consume child command flags, and drop the release-owned changelog entry from the PR diff.
* fix(replay): propagate cleanup failures through cli
Ensure replay-mode transport close failures escape normal runtime and CLI cleanup after best-effort shutdown has completed. Add runtime and CLI regressions for partial recordings that leave requests unreplayed.
* fix: harden record replay runtime paths
* test: align replay fixtures with windows home
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
The daemon host never passed allowCachedAuth when calling
runtime.callTool() or runtime.listTools(), and the KeepAliveRuntime
callTool wrapper did not forward it to the daemon client either.
Without allowCachedAuth, createClientContext skips
applyCachedAuthIfAvailable, so cached OAuth tokens are never read
and every daemon-managed OAuth server fails after token expiry.
Changes:
- protocol.ts: add allowCachedAuth to CallToolParams
- host.ts: pass allowCachedAuth in callTool and listTools handlers
- runtime-wrapper.ts: forward allowCachedAuth in callTool daemon path
Fixesopenclaw/mcporter#181
Related openclaw/mcporter#179