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>
290 lines
9.6 KiB
TypeScript
290 lines
9.6 KiB
TypeScript
import type { Server as HttpServer } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
import express from 'express';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { z } from 'zod';
|
|
import { createRuntime } from '../src/runtime.js';
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.get('/mcp', (_req, res) => {
|
|
res.sendStatus(405);
|
|
});
|
|
|
|
const server = new McpServer({
|
|
name: 'integration-demo',
|
|
version: '1.0.0',
|
|
});
|
|
|
|
server.registerTool(
|
|
'add',
|
|
{
|
|
title: 'Addition Tool',
|
|
description: 'Add two numbers',
|
|
inputSchema: { a: z.number(), b: z.number() },
|
|
outputSchema: { result: z.number() },
|
|
},
|
|
async ({ a, b }) => {
|
|
const result = { result: a + b };
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
structuredContent: result,
|
|
};
|
|
}
|
|
);
|
|
|
|
server.registerResource(
|
|
'greeting',
|
|
new ResourceTemplate('greeting://{name}', { list: undefined }),
|
|
{
|
|
title: 'Greeting',
|
|
description: 'Dynamic greeting resource',
|
|
},
|
|
async (uri, { name }) => {
|
|
const normalizedName = typeof name === 'string' ? name : Array.isArray(name) ? name.join(', ') : 'friend';
|
|
|
|
return {
|
|
contents: [
|
|
{
|
|
uri: uri.href,
|
|
text: `Hello, ${normalizedName}!`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
);
|
|
|
|
app.post('/mcp', async (req, res) => {
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: undefined,
|
|
enableJsonResponse: true,
|
|
});
|
|
|
|
res.on('close', () => {
|
|
transport.close().catch(() => {});
|
|
});
|
|
|
|
await server.connect(transport);
|
|
await transport.handleRequest(req, res, req.body);
|
|
});
|
|
|
|
let httpServer: HttpServer;
|
|
let baseUrl: URL;
|
|
|
|
describe('runtime integration', () => {
|
|
beforeAll(async () => {
|
|
httpServer = app.listen(0, '127.0.0.1');
|
|
await new Promise<void>((resolve, reject) => {
|
|
httpServer.once('listening', resolve);
|
|
httpServer.once('error', reject);
|
|
});
|
|
const address = httpServer.address() as AddressInfo;
|
|
baseUrl = new URL(`http://127.0.0.1:${address.port}/mcp`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
|
});
|
|
|
|
it('lists tools and calls a tool over HTTP', async () => {
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const tools = await runtime.listTools('integration');
|
|
expect(tools.some((tool) => tool.name === 'add')).toBe(true);
|
|
|
|
const result = (await runtime.callTool('integration', 'add', {
|
|
args: { a: 3, b: 4 },
|
|
})) as { structuredContent?: { result: number } };
|
|
|
|
expect(result.structuredContent?.result).toBe(7);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('lists and reads resources over HTTP', async () => {
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const listed = (await runtime.listResources('integration')) as { resources?: Array<{ uri: string }> };
|
|
expect(Array.isArray(listed.resources)).toBe(true);
|
|
|
|
const result = (await runtime.readResource('integration', 'greeting://Ada')) as {
|
|
contents?: Array<{ uri: string; text?: string }>;
|
|
};
|
|
expect(result.contents?.[0]?.uri).toBe('greeting://Ada');
|
|
expect(result.contents?.[0]?.text).toBe('Hello, Ada!');
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('reuses cached connection when disableOAuth: true is passed', async () => {
|
|
// Headless-daemon use case: the caller wants OAuth suppression
|
|
// (no browser launches) but still expects connection caching so
|
|
// every callTool doesn't spawn a fresh transport. Previously the
|
|
// only way to suppress OAuth was `maxOAuthAttempts: 0`, which
|
|
// forced `useCache = false` as a side effect — see the connect()
|
|
// gate. `disableOAuth: true` preserves caching.
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const first = await runtime.connect('integration', { disableOAuth: true });
|
|
const second = await runtime.connect('integration', { disableOAuth: true });
|
|
expect(second).toBe(first);
|
|
|
|
// close() reaps the cached client.
|
|
await runtime.close('integration');
|
|
const reopened = await runtime.connect('integration', { disableOAuth: true });
|
|
expect(reopened).not.toBe(first);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('treats disableOAuth: false like omitted for cache identity', async () => {
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const explicitFalse = await runtime.connect('integration', { disableOAuth: false });
|
|
const omitted = await runtime.connect('integration', {});
|
|
expect(omitted).toBe(explicitFalse);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('maxOAuthAttempts: 0 still bypasses the cache (existing contract preserved)', async () => {
|
|
// Regression guard: callers passing maxOAuthAttempts: 0 today get
|
|
// a fresh client per call. That contract is unchanged — only the
|
|
// new `disableOAuth` flag enables caching with OAuth suppression.
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const first = await runtime.connect('integration', { maxOAuthAttempts: 0 });
|
|
const second = await runtime.connect('integration', { maxOAuthAttempts: 0 });
|
|
expect(second).not.toBe(first);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('keeps separate cached clients when disableOAuth flag changes', async () => {
|
|
// Connections established with disableOAuth: true vs without are
|
|
// semantically different (the former cannot inherit an OAuth
|
|
// session that may refresh into a flow). The cache slot must not
|
|
// be shared across that boundary.
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const cached = await runtime.connect('integration', { disableOAuth: true });
|
|
const withFlowAllowed = await runtime.connect('integration', {});
|
|
expect(withFlowAllowed).not.toBe(cached);
|
|
const cachedAgain = await runtime.connect('integration', { disableOAuth: true });
|
|
expect(cachedAgain).toBe(cached);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('preserves the cached client across connect(disableOAuth:true) → callTool() (no implicit eviction)', async () => {
|
|
// Regression for the PR-198 review note (Codex r3366238654): the
|
|
// documented headless setup is `await runtime.connect(server, {
|
|
// disableOAuth: true })`. That call stored the cache slot with
|
|
// `allowCachedAuth: undefined`. The subsequent internal
|
|
// `callTool()` path forces `allowCachedAuth: true`, and the
|
|
// cache-match check (existing.allowCachedAuth === options.allowCachedAuth
|
|
// || options.allowCachedAuth === undefined) treated the two as
|
|
// structurally different — every first callTool evicted and
|
|
// reopened the transport. Defeats the pooling guarantee for the
|
|
// common pre-connect path.
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const initial = await runtime.connect('integration', { disableOAuth: true });
|
|
|
|
const callResult = (await runtime.callTool('integration', 'add', {
|
|
args: { a: 1, b: 2 },
|
|
})) as { structuredContent?: { result: number } };
|
|
expect(callResult.structuredContent?.result).toBe(3);
|
|
|
|
// After callTool, the cache slot should still hold the same
|
|
// ClientContext established by the prior connect() — no eviction,
|
|
// no extra transport spawned.
|
|
const afterCall = await runtime.connect('integration', { disableOAuth: true });
|
|
expect(afterCall).toBe(initial);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
|
|
it('preserves the cached client across connect(disableOAuth:true) → listTools() (no implicit eviction)', async () => {
|
|
// Same shape as the callTool regression: listTools also forces
|
|
// `allowCachedAuth: options.allowCachedAuth ?? true` internally,
|
|
// so the pre-connected slot was being evicted on first listTools.
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'integration',
|
|
description: 'Integration test server',
|
|
command: { kind: 'http', url: baseUrl },
|
|
},
|
|
],
|
|
});
|
|
|
|
const initial = await runtime.connect('integration', { disableOAuth: true });
|
|
const tools = await runtime.listTools('integration');
|
|
expect(tools.some((tool) => tool.name === 'add')).toBe(true);
|
|
|
|
const afterList = await runtime.connect('integration', { disableOAuth: true });
|
|
expect(afterList).toBe(initial);
|
|
|
|
await runtime.close('integration');
|
|
});
|
|
});
|