Some checks failed
* 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>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import fs from 'node:fs';
|
|
import type { EphemeralServerSpec } from './adhoc-server.js';
|
|
import { parseLeadingCallExpression } from './call-argument-expression.js';
|
|
import {
|
|
type CoercionMode,
|
|
coerceValue,
|
|
parseKeyValueToken,
|
|
shouldPromoteSelectorToCommand,
|
|
} from './call-argument-values.js';
|
|
import { buildUnknownCallFlagMessage } from './call-help.js';
|
|
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
|
import { CliUsageError } from './errors.js';
|
|
import { consumeOutputFormat } from './output-format.js';
|
|
import type { OutputFormat } from './output-utils.js';
|
|
import { consumeTimeoutFlag } from './timeouts.js';
|
|
|
|
export interface CallArgsParseResult {
|
|
selector?: string;
|
|
server?: string;
|
|
tool?: string;
|
|
args: Record<string, unknown>;
|
|
schemaStringCoercionCandidates?: Record<string, string>;
|
|
schemaArrayCoercionCandidates?: Record<string, string>;
|
|
positionalArgs?: unknown[];
|
|
tailLog: boolean;
|
|
output: OutputFormat;
|
|
timeoutMs?: number;
|
|
disableOAuth?: boolean;
|
|
ephemeral?: EphemeralServerSpec;
|
|
rawStrings?: boolean;
|
|
saveImagesDir?: string;
|
|
}
|
|
|
|
interface FlagParseState {
|
|
coercionMode: CoercionMode;
|
|
}
|
|
|
|
interface FlagHandlerContext {
|
|
args: string[];
|
|
index: number;
|
|
result: CallArgsParseResult;
|
|
state: FlagParseState;
|
|
}
|
|
|
|
type FlagHandler = (context: FlagHandlerContext) => number;
|
|
|
|
interface ScannedCallTokens {
|
|
positional: string[];
|
|
literalPositional: string[];
|
|
}
|
|
|
|
interface CallExpressionResolution {
|
|
callExpressionProvidedServer: boolean;
|
|
callExpressionProvidedTool: boolean;
|
|
}
|
|
|
|
const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
|
'--server': handleServerFlag,
|
|
'--mcp': handleServerFlag,
|
|
'--tool': handleToolFlag,
|
|
'--timeout': handleTimeoutFlag,
|
|
'--tail-log': handleTailLogFlag,
|
|
'--no-oauth': handleDisableOAuthFlag,
|
|
'--save-images': handleSaveImagesFlag,
|
|
'--yes': handleNoopFlag,
|
|
'--raw-strings': handleRawStringsFlag,
|
|
'--no-coerce': handleNoCoerceFlag,
|
|
'--args': handleArgsFlag,
|
|
'--json': handleJsonArgsFlag,
|
|
};
|
|
|
|
export function parseCallArguments(args: string[]): CallArgsParseResult {
|
|
const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' };
|
|
const flagState: FlagParseState = { coercionMode: 'default' };
|
|
const ephemeral = extractEphemeralServerFlags(args);
|
|
result.ephemeral = ephemeral;
|
|
result.output = consumeOutputFormat(args, {
|
|
defaultFormat: 'auto',
|
|
});
|
|
const { positional, literalPositional } = scanCallTokens(args, result, flagState);
|
|
const { callExpressionProvidedServer, callExpressionProvidedTool } = applyLeadingCallExpression(positional, result);
|
|
resolveSelectorAndTool(positional, result, callExpressionProvidedServer, callExpressionProvidedTool);
|
|
applyTrailingArguments(positional, result, flagState);
|
|
appendLiteralPositionalArguments(literalPositional, result, flagState);
|
|
return result;
|
|
}
|
|
|
|
function scanCallTokens(args: string[], result: CallArgsParseResult, state: FlagParseState): ScannedCallTokens {
|
|
const positional: string[] = [];
|
|
const literalPositional: string[] = [];
|
|
let index = 0;
|
|
while (index < args.length) {
|
|
const token = args[index];
|
|
if (!token) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (token === '--') {
|
|
literalPositional.push(...args.slice(index + 1).filter(Boolean));
|
|
break;
|
|
}
|
|
const flagHandler = FLAG_HANDLERS[token];
|
|
if (flagHandler) {
|
|
index = flagHandler({ args, index, result, state });
|
|
continue;
|
|
}
|
|
if (token.startsWith('--')) {
|
|
index = handleNamedArgumentFlag({ args, index, result, state });
|
|
continue;
|
|
}
|
|
positional.push(token);
|
|
index += 1;
|
|
}
|
|
return { positional, literalPositional };
|
|
}
|
|
|
|
function applyLeadingCallExpression(positional: string[], result: CallArgsParseResult): CallExpressionResolution {
|
|
if (positional.length === 0) {
|
|
return { callExpressionProvidedServer: false, callExpressionProvidedTool: false };
|
|
}
|
|
const rawToken = positional[0] ?? '';
|
|
const callExpression = parseLeadingCallExpression(rawToken);
|
|
if (!callExpression) {
|
|
return { callExpressionProvidedServer: false, callExpressionProvidedTool: false };
|
|
}
|
|
positional.shift();
|
|
if (callExpression.server) {
|
|
if (result.server && result.server !== callExpression.server) {
|
|
throw new Error(
|
|
`Conflicting server names: '${result.server}' from flags and '${callExpression.server}' from call expression.`
|
|
);
|
|
}
|
|
result.server = result.server ?? callExpression.server;
|
|
}
|
|
if (result.tool && result.tool !== callExpression.tool) {
|
|
throw new Error(
|
|
`Conflicting tool names: '${result.tool}' from flags and '${callExpression.tool}' from call expression.`
|
|
);
|
|
}
|
|
result.tool = callExpression.tool;
|
|
Object.assign(result.args, callExpression.args);
|
|
if (callExpression.positionalArgs && callExpression.positionalArgs.length > 0) {
|
|
result.positionalArgs = [...(result.positionalArgs ?? []), ...callExpression.positionalArgs];
|
|
}
|
|
return {
|
|
callExpressionProvidedServer: Boolean(callExpression.server),
|
|
callExpressionProvidedTool: Boolean(callExpression.tool),
|
|
};
|
|
}
|
|
|
|
function resolveSelectorAndTool(
|
|
positional: string[],
|
|
result: CallArgsParseResult,
|
|
callExpressionProvidedServer: boolean,
|
|
callExpressionProvidedTool: boolean
|
|
): void {
|
|
if (!result.selector && positional.length > 0 && !callExpressionProvidedServer && !result.server) {
|
|
result.selector = positional.shift();
|
|
}
|
|
if (
|
|
!result.server &&
|
|
result.selector &&
|
|
shouldPromoteSelectorToCommand(result.selector) &&
|
|
!result.ephemeral?.stdioCommand
|
|
) {
|
|
result.ephemeral = { ...result.ephemeral, stdioCommand: result.selector };
|
|
result.selector = undefined;
|
|
}
|
|
const nextPositional = positional[0];
|
|
if (
|
|
!result.tool &&
|
|
nextPositional !== undefined &&
|
|
!nextPositional.includes('=') &&
|
|
!nextPositional.includes(':') &&
|
|
!callExpressionProvidedTool
|
|
) {
|
|
result.tool = positional.shift();
|
|
}
|
|
}
|
|
|
|
function applyTrailingArguments(positional: string[], result: CallArgsParseResult, state: FlagParseState): void {
|
|
const trailingPositional: unknown[] = [];
|
|
for (let index = 0; index < positional.length; ) {
|
|
const token = positional[index];
|
|
if (!token) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const parsed = parseKeyValueToken(token, positional[index + 1]);
|
|
if (!parsed) {
|
|
trailingPositional.push(coerceValue(token, state.coercionMode));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
index += parsed.consumed;
|
|
const value = coerceValue(parsed.rawValue, state.coercionMode);
|
|
if (parsed.key === 'tool' && !result.tool) {
|
|
if (typeof value !== 'string') {
|
|
throw new Error("Argument 'tool' must be a string value.");
|
|
}
|
|
result.tool = value as string;
|
|
continue;
|
|
}
|
|
if (parsed.key === 'server' && !result.server) {
|
|
if (typeof value !== 'string') {
|
|
throw new Error("Argument 'server' must be a string value.");
|
|
}
|
|
result.server = value as string;
|
|
continue;
|
|
}
|
|
if (state.coercionMode === 'default' && typeof value === 'number') {
|
|
result.schemaStringCoercionCandidates ??= {};
|
|
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
|
|
}
|
|
result.args[parsed.key] = value;
|
|
}
|
|
if (trailingPositional.length > 0) {
|
|
result.positionalArgs = [...(result.positionalArgs ?? []), ...trailingPositional];
|
|
}
|
|
}
|
|
|
|
function appendLiteralPositionalArguments(
|
|
literalPositional: string[],
|
|
result: CallArgsParseResult,
|
|
state: FlagParseState
|
|
): void {
|
|
if (literalPositional.length === 0) {
|
|
return;
|
|
}
|
|
result.positionalArgs = [
|
|
...(result.positionalArgs ?? []),
|
|
...literalPositional.map((token) => coerceValue(token, state.coercionMode)),
|
|
];
|
|
}
|
|
|
|
function handleServerFlag(context: FlagHandlerContext): number {
|
|
const token = context.args[context.index] ?? '--server';
|
|
context.result.server = consumeFlagValue(context.args, context.index, token);
|
|
return context.index + 2;
|
|
}
|
|
|
|
function handleToolFlag(context: FlagHandlerContext): number {
|
|
context.result.tool = consumeFlagValue(context.args, context.index, '--tool');
|
|
return context.index + 2;
|
|
}
|
|
|
|
function handleTimeoutFlag(context: FlagHandlerContext): number {
|
|
context.result.timeoutMs = consumeTimeoutFlag(context.args, context.index, {
|
|
flagName: '--timeout',
|
|
missingValueMessage: '--timeout requires a value (milliseconds).',
|
|
});
|
|
// consumeTimeoutFlag removes the flag/value pair in-place; stay on the same index.
|
|
return context.index;
|
|
}
|
|
|
|
function handleTailLogFlag(context: FlagHandlerContext): number {
|
|
context.result.tailLog = true;
|
|
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,
|
|
context.index,
|
|
'--save-images',
|
|
'--save-images requires a directory path.'
|
|
);
|
|
return context.index + 2;
|
|
}
|
|
|
|
function handleNoopFlag(context: FlagHandlerContext): number {
|
|
return context.index + 1;
|
|
}
|
|
|
|
function handleRawStringsFlag(context: FlagHandlerContext): number {
|
|
context.state.coercionMode = 'raw-strings';
|
|
context.result.rawStrings = true;
|
|
return context.index + 1;
|
|
}
|
|
|
|
function handleNoCoerceFlag(context: FlagHandlerContext): number {
|
|
context.state.coercionMode = 'none';
|
|
context.result.rawStrings = true;
|
|
return context.index + 1;
|
|
}
|
|
|
|
function handleArgsFlag(context: FlagHandlerContext): number {
|
|
return consumeJsonArgsFlag(context, '--args', '--args requires a JSON value.');
|
|
}
|
|
|
|
function handleJsonArgsFlag(context: FlagHandlerContext): number {
|
|
return consumeJsonArgsFlag(context, '--json', '--json requires a JSON object value.');
|
|
}
|
|
|
|
function consumeJsonArgsFlag(context: FlagHandlerContext, flagName: string, missingValueMessage: string): number {
|
|
const rawFlagValue = consumeFlagValue(context.args, context.index, flagName, missingValueMessage);
|
|
const raw = rawFlagValue === '-' ? fs.readFileSync(0, 'utf8') : rawFlagValue;
|
|
let decoded: unknown;
|
|
try {
|
|
decoded = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(`Unable to parse ${flagName}: ${(error as Error).message}`, { cause: error });
|
|
}
|
|
if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
|
|
throw new Error(`Unable to parse ${flagName}: ${flagName} must be a JSON object.`);
|
|
}
|
|
Object.assign(context.result.args, decoded);
|
|
return context.index + 2;
|
|
}
|
|
|
|
function handleNamedArgumentFlag(context: FlagHandlerContext): number {
|
|
const token = context.args[context.index] ?? '';
|
|
const body = token.slice(2);
|
|
const eqIndex = body.indexOf('=');
|
|
const rawKey = eqIndex === -1 ? body : body.slice(0, eqIndex);
|
|
const key = normalizeLongFlagArgumentKey(rawKey);
|
|
if (!key) {
|
|
throw new CliUsageError(buildUnknownCallFlagMessage(token));
|
|
}
|
|
|
|
const rawValue =
|
|
eqIndex === -1
|
|
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
|
: body.slice(eqIndex + 1);
|
|
const value = coerceValue(rawValue, context.state.coercionMode);
|
|
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
|
context.result.schemaStringCoercionCandidates ??= {};
|
|
context.result.schemaStringCoercionCandidates[key] = rawValue;
|
|
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
|
context.result.schemaArrayCoercionCandidates ??= {};
|
|
context.result.schemaArrayCoercionCandidates[key] = rawValue;
|
|
}
|
|
context.result.args[key] = value;
|
|
return context.index + (eqIndex === -1 ? 2 : 1);
|
|
}
|
|
|
|
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
|
if (!rawKey || rawKey.startsWith('-')) {
|
|
return '';
|
|
}
|
|
return rawKey.replace(/-([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase());
|
|
}
|
|
|
|
function consumeFlagValue(args: string[], index: number, token: string, missingValueMessage?: string): string {
|
|
const value = args[index + 1];
|
|
if (value) {
|
|
return value;
|
|
}
|
|
throw new Error(missingValueMessage ?? `Flag '${token}' requires a value.`);
|
|
}
|