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>
606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { ServerDefinition } from '../src/config';
|
|
import type { CallResult } from '../src/index.js';
|
|
import type { Runtime, ServerToolInfo } from '../src/runtime';
|
|
import { writeSchemaCache } from '../src/schema-cache';
|
|
import { createServerProxy } from '../src/server-proxy';
|
|
|
|
function createMockRuntime(
|
|
toolSchemas: Record<string, unknown> = {},
|
|
listToolsImpl?: () => Promise<ServerToolInfo[]>,
|
|
definitionOverrides: Partial<ServerDefinition> = {}
|
|
) {
|
|
const listTools = listToolsImpl
|
|
? vi.fn(listToolsImpl)
|
|
: vi.fn(async () =>
|
|
Object.entries(toolSchemas).map(([name, schema]) => ({
|
|
name,
|
|
description: '',
|
|
inputSchema: schema,
|
|
}))
|
|
);
|
|
const definition: ServerDefinition = {
|
|
name: definitionOverrides.name ?? 'mock',
|
|
description: definitionOverrides.description,
|
|
command: definitionOverrides.command ?? {
|
|
kind: 'stdio',
|
|
command: 'mock',
|
|
args: [],
|
|
cwd: process.cwd(),
|
|
},
|
|
env: definitionOverrides.env,
|
|
auth: definitionOverrides.auth,
|
|
tokenCacheDir: definitionOverrides.tokenCacheDir,
|
|
clientName: definitionOverrides.clientName,
|
|
};
|
|
|
|
return {
|
|
callTool: vi.fn(async (_, __, options) => options),
|
|
listTools,
|
|
getDefinition: vi.fn(() => definition),
|
|
};
|
|
}
|
|
|
|
describe('createServerProxy', () => {
|
|
it('maps camelCase property names to kebab-case tool names', async () => {
|
|
const runtime = createMockRuntime({
|
|
'resolve-library-id': {
|
|
type: 'object',
|
|
properties: {
|
|
libraryName: { type: 'string' },
|
|
},
|
|
required: ['libraryName'],
|
|
},
|
|
});
|
|
const context7 = createServerProxy(runtime as unknown as Runtime, 'context7') as Record<string, unknown>;
|
|
|
|
const resolver = context7.resolveLibraryId as (args: unknown) => Promise<CallResult>;
|
|
const result = await resolver({ libraryName: 'react' });
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('context7', 'resolve-library-id', { args: { libraryName: 'react' } });
|
|
expect(result.raw).toEqual({ args: { libraryName: 'react' } });
|
|
});
|
|
|
|
it('merges args and options when both are provided', async () => {
|
|
const runtime = createMockRuntime({
|
|
'some-tool': {
|
|
type: 'object',
|
|
properties: {
|
|
foo: { type: 'string' },
|
|
},
|
|
required: ['foo'],
|
|
},
|
|
});
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'foo') as Record<string, unknown>;
|
|
|
|
const fn = proxy.someTool as (args: unknown, options: unknown) => Promise<CallResult>;
|
|
const result = await fn({ foo: 'bar' }, { tailLog: true });
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('foo', 'some-tool', {
|
|
args: { foo: 'bar' },
|
|
tailLog: true,
|
|
});
|
|
expect(result.raw).toEqual({ args: { foo: 'bar' }, tailLog: true });
|
|
});
|
|
|
|
it('supports passing full call options as the first argument', async () => {
|
|
const runtime = createMockRuntime();
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'bar') as Record<string, unknown>;
|
|
|
|
const fn = proxy.otherTool as (options: unknown) => Promise<CallResult>;
|
|
const result = await fn({ args: { value: 1 }, tailLog: true });
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('bar', 'other-tool', {
|
|
args: { value: 1 },
|
|
tailLog: true,
|
|
});
|
|
expect(result.raw).toEqual({ args: { value: 1 }, tailLog: true });
|
|
});
|
|
|
|
it('hydrates schemas from disk cache without querying the server', async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-schema-cache-'));
|
|
try {
|
|
const definition: ServerDefinition = {
|
|
name: 'cached',
|
|
description: '',
|
|
command: {
|
|
kind: 'stdio',
|
|
command: 'mock',
|
|
args: [],
|
|
cwd: process.cwd(),
|
|
},
|
|
tokenCacheDir: tmpDir,
|
|
};
|
|
await writeSchemaCache(definition, {
|
|
updatedAt: new Date().toISOString(),
|
|
tools: {
|
|
'some-tool': {
|
|
type: 'object',
|
|
properties: { foo: { type: 'string' } },
|
|
required: ['foo'],
|
|
},
|
|
},
|
|
});
|
|
|
|
const runtime = createMockRuntime({}, undefined, definition);
|
|
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'cached') as Record<string, unknown>;
|
|
|
|
const fn = proxy.someTool as (args: unknown) => Promise<CallResult>;
|
|
const result = await fn({ foo: 'bar' });
|
|
|
|
expect(result.raw).toEqual({ args: { foo: 'bar' } });
|
|
expect(runtime.listTools).toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('persists schemas to disk after fetching', async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-schema-write-'));
|
|
try {
|
|
const runtime = createMockRuntime(
|
|
{
|
|
'some-tool': {
|
|
type: 'object',
|
|
properties: { foo: { type: 'string' } },
|
|
required: ['foo'],
|
|
},
|
|
},
|
|
undefined,
|
|
{
|
|
name: 'persist',
|
|
tokenCacheDir: tmpDir,
|
|
}
|
|
);
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'persist') as Record<string, unknown>;
|
|
|
|
const fn = proxy.someTool as (args: unknown) => Promise<CallResult>;
|
|
await fn({ foo: 'bar' });
|
|
|
|
const snapshotPath = path.join(tmpDir, 'schema.json');
|
|
const snapshotRaw = await fs.readFile(snapshotPath, 'utf8');
|
|
const snapshot = JSON.parse(snapshotRaw) as {
|
|
tools: Record<string, unknown>;
|
|
};
|
|
expect(Object.keys(snapshot.tools)).toContain('some-tool');
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses provided initial schemas without hitting listTools', async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-schema-initial-'));
|
|
try {
|
|
const runtime = createMockRuntime(
|
|
{},
|
|
async () => {
|
|
throw new Error('listTools should not run when initial schemas set');
|
|
},
|
|
{
|
|
name: 'initial',
|
|
tokenCacheDir: tmpDir,
|
|
}
|
|
);
|
|
|
|
const initial = {
|
|
'some-tool': {
|
|
type: 'object',
|
|
properties: { foo: { type: 'string' } },
|
|
required: ['foo'],
|
|
},
|
|
};
|
|
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'initial', { initialSchemas: initial }) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
|
|
const fn = proxy.someTool as (args: unknown) => Promise<CallResult>;
|
|
await fn({ foo: 'bar' });
|
|
|
|
const snapshotPath = path.join(tmpDir, 'schema.json');
|
|
const snapshotRaw = await fs.readFile(snapshotPath, 'utf8');
|
|
const snapshot = JSON.parse(snapshotRaw) as {
|
|
tools: Record<string, unknown>;
|
|
};
|
|
expect(Object.keys(snapshot.tools)).toContain('some-tool');
|
|
expect(runtime.listTools).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('applies schema defaults and validates required arguments', async () => {
|
|
const runtime = createMockRuntime({
|
|
someTool: {
|
|
type: 'object',
|
|
properties: {
|
|
foo: { type: 'number', default: 42 },
|
|
bar: { type: 'string' },
|
|
},
|
|
required: ['foo'],
|
|
},
|
|
otherTool: {
|
|
type: 'object',
|
|
required: ['value'],
|
|
},
|
|
});
|
|
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'test') as Record<string, unknown>;
|
|
|
|
const someTool = proxy.someTool as (options?: unknown) => Promise<CallResult>;
|
|
const result = await someTool({ bar: 'baz' });
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('test', 'some-tool', {
|
|
args: { foo: 42, bar: 'baz' },
|
|
});
|
|
expect(result.raw).toEqual({ args: { foo: 42, bar: 'baz' } });
|
|
|
|
const otherTool = proxy.otherTool as () => Promise<CallResult>;
|
|
await expect(otherTool()).rejects.toThrow('Missing required arguments');
|
|
expect(runtime.callTool).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('continues when metadata fetch fails', async () => {
|
|
const runtime = createMockRuntime({}, () => Promise.reject(new Error('metadata failure')));
|
|
|
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'foo') as Record<string, unknown>;
|
|
|
|
const fn = proxy.someTool as (args: unknown) => Promise<CallResult>;
|
|
const result = await fn({ foo: 'bar' });
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('foo', 'some-tool', {
|
|
foo: 'bar',
|
|
});
|
|
expect(result.raw).toEqual({ foo: 'bar' });
|
|
});
|
|
|
|
it('maps primitive positional arguments onto required schema fields', async () => {
|
|
const runtime = createMockRuntime({
|
|
'get-library-docs': {
|
|
type: 'object',
|
|
properties: {
|
|
context7CompatibleLibraryID: { type: 'string' },
|
|
format: { type: 'string', default: 'markdown' },
|
|
},
|
|
required: ['context7CompatibleLibraryID'],
|
|
},
|
|
});
|
|
|
|
const context7 = createServerProxy(runtime as unknown as Runtime, 'context7') as Record<string, unknown>;
|
|
|
|
const fn = context7.getLibraryDocs as (arg: unknown) => Promise<CallResult>;
|
|
const result = await fn('/ids/react');
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('context7', 'get-library-docs', {
|
|
args: {
|
|
context7CompatibleLibraryID: '/ids/react',
|
|
format: 'markdown',
|
|
},
|
|
});
|
|
expect(result.raw).toEqual({
|
|
args: {
|
|
context7CompatibleLibraryID: '/ids/react',
|
|
format: 'markdown',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('supports multi-field positional arguments with additional arg bags', async () => {
|
|
const runtime = createMockRuntime({
|
|
firecrawl_scrape: {
|
|
type: 'object',
|
|
properties: {
|
|
url: { type: 'string' },
|
|
formats: { type: ['string', 'array'], default: 'markdown' },
|
|
waitFor: { type: 'number' },
|
|
mobile: { type: 'boolean', default: false },
|
|
},
|
|
required: ['url'],
|
|
},
|
|
});
|
|
|
|
const firecrawl = createServerProxy(runtime as unknown as Runtime, 'firecrawl') as Record<string, unknown>;
|
|
|
|
const fn = firecrawl.firecrawlScrape as (
|
|
url: unknown,
|
|
formats: unknown,
|
|
args: unknown,
|
|
options: unknown
|
|
) => Promise<CallResult>;
|
|
const result = await fn('https://example.com/docs', ['markdown', 'html'], { waitFor: 5000 }, { tailLog: true });
|
|
|
|
expect(runtime.callTool).toHaveBeenCalledWith('firecrawl', 'firecrawl_scrape', {
|
|
args: {
|
|
url: 'https://example.com/docs',
|
|
formats: ['markdown', 'html'],
|
|
waitFor: 5000,
|
|
mobile: false,
|
|
},
|
|
tailLog: true,
|
|
});
|
|
expect(result.raw).toEqual({
|
|
args: {
|
|
url: 'https://example.com/docs',
|
|
formats: ['markdown', 'html'],
|
|
waitFor: 5000,
|
|
mobile: false,
|
|
},
|
|
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 },
|
|
});
|
|
});
|
|
});
|