* 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>
Move keep-alive daemon retry diagnostics to stderr so mcporter call --output json keeps stdout parseable after daemon recovery. Current main already covers the structuredContent and raw-envelope JSON fallbacks from the same issue; this PR adds explicit regression coverage for MCP isError envelopes and daemon retry logging.\n\nVerified locally with:\n- pnpm exec vitest run tests/cli-output-utils.test.ts tests/keep-alive-runtime.test.ts tests/result-utils.test.ts\n- pnpm check\n- PNPM_CONFIG_LOGLEVEL=error npm_config_loglevel=error pnpm test\n- pnpm build\n- git diff --check\n\nFixes #160.\n\nCo-authored-by: clawSean <260045960+clawSean@users.noreply.github.com>