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>
517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
connectWithAuth: vi.fn(),
|
|
createOAuthSession: vi.fn(),
|
|
readCachedAccessToken: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../src/runtime/oauth.js', async () => {
|
|
const actual = await vi.importActual('../src/runtime/oauth.js');
|
|
return {
|
|
...actual,
|
|
connectWithAuth: mocks.connectWithAuth,
|
|
};
|
|
});
|
|
|
|
vi.mock('../src/oauth.js', async () => {
|
|
const actual = await vi.importActual('../src/oauth.js');
|
|
return {
|
|
...actual,
|
|
createOAuthSession: mocks.createOAuthSession,
|
|
};
|
|
});
|
|
|
|
vi.mock('../src/oauth-persistence.js', async () => {
|
|
const actual = await vi.importActual('../src/oauth-persistence.js');
|
|
return {
|
|
...actual,
|
|
readCachedAccessToken: mocks.readCachedAccessToken,
|
|
};
|
|
});
|
|
|
|
import type { ServerDefinition } from '../src/config.js';
|
|
import * as oauthModule from '../src/oauth.js';
|
|
import { markOAuthFlowError, markPostAuthConnectError } from '../src/runtime/oauth.js';
|
|
import { createClientContext } from '../src/runtime/transport.js';
|
|
import {
|
|
clientInfo,
|
|
createLogger,
|
|
createMockOAuthSession,
|
|
createPromotionRecorder,
|
|
resetLogger,
|
|
stubHttpDefinition,
|
|
stubOAuthHttpDefinition,
|
|
} from './helpers/runtime-test-helpers.js';
|
|
|
|
const logger = createLogger();
|
|
|
|
beforeEach(() => {
|
|
resetLogger(logger);
|
|
mocks.connectWithAuth.mockReset();
|
|
mocks.connectWithAuth.mockImplementation(async (client, transport) => {
|
|
await client.connect(transport);
|
|
return transport;
|
|
});
|
|
mocks.createOAuthSession.mockReset();
|
|
mocks.createOAuthSession.mockResolvedValue(createMockOAuthSession());
|
|
mocks.readCachedAccessToken.mockReset();
|
|
mocks.readCachedAccessToken.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('createClientContext (HTTP)', () => {
|
|
it('falls back to SSE when primary connect fails', async () => {
|
|
const definition = stubHttpDefinition('https://example.com/mcp');
|
|
|
|
const clientConnect = vi
|
|
.spyOn(Client.prototype, 'connect')
|
|
.mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw new Error('network down');
|
|
})
|
|
.mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
});
|
|
|
|
const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
|
|
|
|
expect(context.transport).toBeInstanceOf(SSEClientTransport);
|
|
expect(clientConnect).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not fall back to SSE after the OAuth flow fails', async () => {
|
|
const definition = stubOAuthHttpDefinition('https://example.com/secure');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw markOAuthFlowError(new Error('OAuth error: invalid_client'));
|
|
})
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
return transport;
|
|
});
|
|
|
|
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 })).rejects.toThrow(
|
|
'OAuth error: invalid_client'
|
|
);
|
|
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(1);
|
|
const transports = mocks.connectWithAuth.mock.calls.map((call) => call[1]);
|
|
expect(transports.every((transport) => transport instanceof StreamableHTTPClientTransport)).toBe(true);
|
|
expect(transports.some((transport) => transport instanceof SSEClientTransport)).toBe(false);
|
|
});
|
|
|
|
it('still falls back to SSE after auth when Streamable HTTP reveals a 405 transport mismatch', async () => {
|
|
const definition = stubOAuthHttpDefinition('https://example.com/legacy-sse');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw markPostAuthConnectError(new StreamableHTTPError(405, 'Failed to open SSE stream: Method Not Allowed'));
|
|
})
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
return transport;
|
|
});
|
|
|
|
const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 });
|
|
|
|
expect(context.transport).toBeInstanceOf(SSEClientTransport);
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('surfaces provider 405 errors after auth instead of falling back to SSE', async () => {
|
|
const definition = stubOAuthHttpDefinition('https://example.com/provider-405');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const error = new Error('token endpoint returned 405') as Error & { code: number };
|
|
error.code = 405;
|
|
throw markOAuthFlowError(error);
|
|
})
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
return transport;
|
|
});
|
|
|
|
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 })).rejects.toThrow(
|
|
'token endpoint returned 405'
|
|
);
|
|
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('still falls back to SSE after auth for generic 405 transport errors', async () => {
|
|
const definition = stubOAuthHttpDefinition('https://example.com/legacy-sse-proxy');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const error = new Error('proxy returned method not allowed') as Error & { status: number };
|
|
error.status = 405;
|
|
throw markPostAuthConnectError(error);
|
|
})
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
return transport;
|
|
});
|
|
|
|
const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 });
|
|
|
|
expect(context.transport).toBeInstanceOf(SSEClientTransport);
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('still falls back to SSE for oauth servers when no Streamable auth challenge was observed', async () => {
|
|
const definition = stubOAuthHttpDefinition('https://example.com/sse-only');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw new Error('HTTP error 405: Method Not Allowed');
|
|
})
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
return transport;
|
|
});
|
|
|
|
const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 });
|
|
|
|
expect(context.transport).toBeInstanceOf(SSEClientTransport);
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('uses cached OAuth tokens for non-interactive HTTP connects even when auth is missing from config', async () => {
|
|
const definition = stubHttpDefinition('https://example.com/secure');
|
|
mocks.readCachedAccessToken.mockResolvedValue('cached-token');
|
|
|
|
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const requestInit = (transport as { _requestInit?: RequestInit })._requestInit;
|
|
expect(requestInit?.headers).toEqual({
|
|
Authorization: 'Bearer cached-token',
|
|
});
|
|
});
|
|
|
|
await createClientContext(definition, logger, clientInfo, {
|
|
maxOAuthAttempts: 0,
|
|
allowCachedAuth: true,
|
|
});
|
|
|
|
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
|
|
expect(mocks.readCachedAccessToken).toHaveBeenCalledWith(definition, logger);
|
|
});
|
|
|
|
it('preserves explicit Authorization headers for refreshable bearer HTTP servers', async () => {
|
|
const definition: ServerDefinition = {
|
|
...stubHttpDefinition('https://example.com/secure'),
|
|
auth: 'refreshable_bearer',
|
|
refresh: { tokenEndpoint: 'https://auth.example.com/token' },
|
|
command: {
|
|
kind: 'http',
|
|
url: new URL('https://example.com/secure'),
|
|
headers: { Authorization: 'Bearer configured-token' },
|
|
},
|
|
};
|
|
mocks.readCachedAccessToken.mockRejectedValue(new Error('invalid_grant'));
|
|
|
|
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const requestInit = (transport as { _requestInit?: RequestInit })._requestInit;
|
|
expect(requestInit?.headers).toEqual({ Authorization: 'Bearer configured-token' });
|
|
});
|
|
|
|
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
|
|
|
|
expect(mocks.readCachedAccessToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails refreshable bearer HTTP configs with no cached token', async () => {
|
|
const definition: ServerDefinition = {
|
|
...stubHttpDefinition('https://example.com/secure'),
|
|
auth: 'refreshable_bearer',
|
|
refresh: { tokenEndpoint: 'https://auth.example.com/token' },
|
|
};
|
|
mocks.readCachedAccessToken.mockResolvedValue(undefined);
|
|
|
|
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 })).rejects.toThrow(
|
|
'no cached access token'
|
|
);
|
|
});
|
|
|
|
it('injects refreshed bearer tokens into configured stdio env', async () => {
|
|
const definition: ServerDefinition = {
|
|
name: 'stdio-refresh',
|
|
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
|
|
auth: 'refreshable_bearer',
|
|
refresh: {
|
|
tokenEndpoint: 'https://auth.example.com/token',
|
|
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
|
|
},
|
|
env: { STATIC_ENV: '1' },
|
|
};
|
|
mocks.readCachedAccessToken.mockResolvedValue('cached-token');
|
|
|
|
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StdioClientTransport);
|
|
const params = (transport as { _serverParams?: { env?: Record<string, string> } })._serverParams;
|
|
expect(params?.env).toEqual(expect.objectContaining({ STATIC_ENV: '1', EXAMPLE_ACCESS_TOKEN: 'cached-token' }));
|
|
});
|
|
|
|
await createClientContext(definition, logger, clientInfo, {
|
|
maxOAuthAttempts: 0,
|
|
});
|
|
});
|
|
|
|
it('fails refreshable bearer stdio configs that do not name the token env var', async () => {
|
|
const definition: ServerDefinition = {
|
|
name: 'stdio-refresh',
|
|
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
|
|
auth: 'refreshable_bearer',
|
|
refresh: {
|
|
tokenEndpoint: 'https://auth.example.com/token',
|
|
},
|
|
};
|
|
|
|
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 })).rejects.toThrow(
|
|
'missing refresh.accessTokenEnv'
|
|
);
|
|
expect(mocks.readCachedAccessToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not promote explicit refreshable bearer HTTP servers to OAuth after 401 errors', async () => {
|
|
const definition: ServerDefinition = {
|
|
...stubHttpDefinition('https://example.com/secure'),
|
|
auth: 'refreshable_bearer',
|
|
refresh: { tokenEndpoint: 'https://auth.example.com/token' },
|
|
};
|
|
mocks.readCachedAccessToken.mockResolvedValue('cached-token');
|
|
|
|
mocks.connectWithAuth.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw new Error('SSE error: Non-200 status code (401)');
|
|
});
|
|
|
|
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 })).rejects.toThrow(
|
|
'Non-200 status code (401)'
|
|
);
|
|
|
|
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses the HTTP/1.1 fetch compatibility path when configured', async () => {
|
|
const definition: ServerDefinition = {
|
|
...stubHttpDefinition('https://example.com/mcp'),
|
|
httpFetch: 'node-http1',
|
|
};
|
|
|
|
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const fetchOverride = (transport as { _fetch?: unknown })._fetch;
|
|
expect(fetchOverride).toEqual(expect.any(Function));
|
|
});
|
|
|
|
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
|
|
});
|
|
|
|
it('uses the HTTP/1.1 fetch compatibility path for Sunsama by default', async () => {
|
|
const definition = stubHttpDefinition('https://api.sunsama.com/mcp');
|
|
|
|
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const fetchOverride = (transport as { _fetch?: unknown })._fetch;
|
|
expect(fetchOverride).toEqual(expect.any(Function));
|
|
});
|
|
|
|
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
|
|
});
|
|
|
|
it('honors explicit default fetch mode for Sunsama', async () => {
|
|
const definition: ServerDefinition = {
|
|
...stubHttpDefinition('https://api.sunsama.com/mcp'),
|
|
httpFetch: 'default',
|
|
};
|
|
|
|
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const fetchOverride = (transport as { _fetch?: unknown })._fetch;
|
|
expect(fetchOverride).toBeUndefined();
|
|
});
|
|
|
|
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');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw new Error('SSE error: Non-200 status code (401)');
|
|
})
|
|
.mockImplementationOnce(async (_client, transport, session) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
expect(session).toBeDefined();
|
|
return transport;
|
|
});
|
|
|
|
const { promotedDefinitions, onDefinitionPromoted } = createPromotionRecorder();
|
|
const context = await createClientContext(definition, logger, clientInfo, {
|
|
maxOAuthAttempts: 1,
|
|
onDefinitionPromoted,
|
|
});
|
|
|
|
expect(context.definition.auth).toBe('oauth');
|
|
expect(mocks.createOAuthSession).toHaveBeenCalledTimes(1);
|
|
expect(promotedDefinitions).toEqual([expect.objectContaining({ auth: 'oauth' })]);
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('promotes ad-hoc HTTP servers after generic 401 errors from the SSE fallback path', async () => {
|
|
const definition = stubHttpDefinition('https://example.com/sse-auth');
|
|
|
|
mocks.connectWithAuth
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
throw new Error('HTTP error 405: Method Not Allowed');
|
|
})
|
|
.mockImplementationOnce(async (_client, transport) => {
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
throw new Error('SSE error: Non-200 status code (401)');
|
|
})
|
|
.mockImplementationOnce(async (_client, transport, session) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
expect(session).toBeDefined();
|
|
return transport;
|
|
});
|
|
|
|
const { promotedDefinitions, onDefinitionPromoted } = createPromotionRecorder();
|
|
const context = await createClientContext(definition, logger, clientInfo, {
|
|
maxOAuthAttempts: 1,
|
|
onDefinitionPromoted,
|
|
});
|
|
|
|
expect(context.definition.auth).toBe('oauth');
|
|
expect(mocks.createOAuthSession).toHaveBeenCalledTimes(1);
|
|
expect(promotedDefinitions).toEqual([expect.objectContaining({ auth: 'oauth' })]);
|
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('drops static Authorization headers for oauth servers but preserves other headers', async () => {
|
|
const definition: ServerDefinition = {
|
|
...stubHttpDefinition('https://example.com/secure'),
|
|
auth: 'oauth',
|
|
command: {
|
|
kind: 'http',
|
|
url: new URL('https://example.com/secure'),
|
|
headers: {
|
|
Authorization: 'Bearer static-token',
|
|
'X-Trace': 'keep-me',
|
|
},
|
|
},
|
|
};
|
|
const createOAuthSessionSpy = vi.spyOn(oauthModule, 'createOAuthSession').mockResolvedValue({
|
|
provider: {} as never,
|
|
waitForAuthorizationCode: vi.fn(),
|
|
close: vi.fn(async () => {}),
|
|
});
|
|
|
|
const clientConnect = vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
const requestInit = (transport as { _requestInit?: RequestInit })._requestInit;
|
|
expect(requestInit?.headers).toEqual({ 'X-Trace': 'keep-me' });
|
|
});
|
|
|
|
const context = await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 });
|
|
|
|
expect(createOAuthSessionSpy).toHaveBeenCalledTimes(1);
|
|
expect(clientConnect).toHaveBeenCalledTimes(1);
|
|
await context.transport.close();
|
|
});
|
|
});
|