fix(runtime): preserve disableOAuth across headless paths (#198)
Some checks failed
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>
This commit is contained in:
parent
8f74252a4d
commit
3e27b64021
@ -4,6 +4,7 @@
|
||||
|
||||
### OAuth
|
||||
|
||||
- Add cache-friendly `disableOAuth` support across headless runtime, CLI, daemon, proxy, and `callOnce` paths so callers can suppress interactive OAuth without losing connection reuse. (Issues #197, #199, #201, thanks @feniix)
|
||||
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
|
||||
|
||||
### CLI
|
||||
|
||||
@ -38,6 +38,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- `--exit-code` – exit 1 when any checked server is unhealthy.
|
||||
- `--quiet` – suppress output and exit 1 when any checked server is unhealthy.
|
||||
- `--timeout <ms>` – per-server timeout when enumerating all servers.
|
||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||
tokens only while keeping eligible connections pooled.
|
||||
|
||||
## `mcporter call <server.tool>`
|
||||
|
||||
@ -52,6 +54,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
||||
- `--no-coerce` – disable all flag-style/positional value coercion.
|
||||
- `--tail-log` – stream tail output when the tool returns log handles.
|
||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||
tokens only while keeping eligible connections pooled.
|
||||
|
||||
## `mcporter resource <server> [uri]`
|
||||
|
||||
@ -63,6 +67,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- `--output auto|text|markdown|json|raw` – choose how to render the response.
|
||||
- `--json` – shortcut for `--output json`.
|
||||
- `--raw` – shortcut for `--output raw`.
|
||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||
tokens only while keeping eligible connections pooled.
|
||||
|
||||
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ Use `createServerProxy(runtime, name)` inside scripts when you want ergonomic ca
|
||||
2. Automatically merges default values.
|
||||
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
|
||||
|
||||
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control.
|
||||
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control. Headless callers that must rely on cached tokens without launching OAuth can pass `disableOAuth: true` to `connect`, `callTool`, `listTools`, resource helpers, and `callOnce`; this suppresses interactive OAuth while keeping eligible connections pooled.
|
||||
|
||||
## Debug + Support Docs
|
||||
|
||||
|
||||
170
examples/headless-pooling-demo.ts
Normal file
170
examples/headless-pooling-demo.ts
Normal file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Demonstration: `disableOAuth: true` provides cache-friendly OAuth
|
||||
* suppression for headless callers.
|
||||
*
|
||||
* Spins up a local mock MCP server (no real auth), then exercises three
|
||||
* patterns side-by-side and counts the distinct ClientContext objects
|
||||
* the runtime hands out:
|
||||
*
|
||||
* 1. Legacy `maxOAuthAttempts: 0` — uncached (existing contract).
|
||||
* 2. `disableOAuth: true` direct connects — pooled.
|
||||
* 3. The documented headless setup — pre-connect with
|
||||
* `disableOAuth: true`, then 5 `callTool` invocations. Verifies the
|
||||
* pre-connected slot is preserved (no implicit eviction).
|
||||
*
|
||||
* Run: pnpm tsx examples/headless-pooling-demo.ts
|
||||
*
|
||||
* Counting strategy: ClientContext object identity. Each call to
|
||||
* `createClientContext` inside the runtime returns a fresh object;
|
||||
* cached calls return the same object. We track the set of unique
|
||||
* objects and report cardinality.
|
||||
*/
|
||||
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import { createRuntime } from '../src/index.js';
|
||||
|
||||
const INVOCATIONS = 5;
|
||||
|
||||
async function startMockServer(): Promise<{ baseUrl: URL; httpServer: HttpServer }> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const mcp = new McpServer({ name: 'demo', version: '1.0.0' });
|
||||
mcp.registerTool(
|
||||
'add',
|
||||
{
|
||||
title: 'Addition',
|
||||
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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/mcp', (_req, res) => res.sendStatus(405));
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on('close', () => {
|
||||
transport.close().catch(() => {});
|
||||
});
|
||||
await mcp.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
const 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;
|
||||
return { baseUrl: new URL(`http://127.0.0.1:${address.port}/mcp`), httpServer };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// The mock MCP server below has no `auth: 'oauth'` definition, so the
|
||||
// OAuth flow is not exercised here. This demo focuses on the
|
||||
// cache-behavior fix (the main fix in PR #198). OAuth-suppression
|
||||
// semantics under `disableOAuth: true` are exercised by the unit
|
||||
// tests in `tests/runtime-transport.test.ts` (shouldEstablishOAuth)
|
||||
// and `tests/runtime-integration.test.ts` (cache + eviction).
|
||||
const { baseUrl, httpServer } = await startMockServer();
|
||||
console.log(`[demo] Mock MCP server listening at ${baseUrl}\n`);
|
||||
|
||||
try {
|
||||
// ----- Pattern A: legacy maxOAuthAttempts: 0 (uncached) ------------
|
||||
{
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'demo',
|
||||
description: 'Demo server',
|
||||
command: { kind: 'http', url: baseUrl },
|
||||
},
|
||||
],
|
||||
});
|
||||
const contexts = new Set<unknown>();
|
||||
for (let i = 0; i < INVOCATIONS; i++) {
|
||||
contexts.add(await runtime.connect('demo', { maxOAuthAttempts: 0 }));
|
||||
}
|
||||
console.log(`[demo] Pattern A — legacy maxOAuthAttempts: 0`);
|
||||
console.log(`[demo] ${INVOCATIONS} connect() calls → ${contexts.size} distinct ClientContexts`);
|
||||
console.log(`[demo] Expected: ${INVOCATIONS} (legacy contract: cache disabled when maxOAuthAttempts is set)`);
|
||||
console.log(`[demo] Result: ${contexts.size === INVOCATIONS ? 'OK' : 'UNEXPECTED'}\n`);
|
||||
await runtime.close();
|
||||
}
|
||||
|
||||
// ----- Pattern B: disableOAuth: true on every connect ---------------
|
||||
{
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'demo',
|
||||
description: 'Demo server',
|
||||
command: { kind: 'http', url: baseUrl },
|
||||
},
|
||||
],
|
||||
});
|
||||
const contexts = new Set<unknown>();
|
||||
for (let i = 0; i < INVOCATIONS; i++) {
|
||||
contexts.add(await runtime.connect('demo', { disableOAuth: true }));
|
||||
}
|
||||
console.log(`[demo] Pattern B — disableOAuth: true on every connect`);
|
||||
console.log(`[demo] ${INVOCATIONS} connect() calls → ${contexts.size} distinct ClientContexts`);
|
||||
console.log(`[demo] Expected: 1 (cache reuse under cache-friendly suppression)`);
|
||||
console.log(`[demo] Result: ${contexts.size === 1 ? 'PASS' : 'FAIL'}\n`);
|
||||
await runtime.close();
|
||||
}
|
||||
|
||||
// ----- Pattern C: documented headless setup + 5 callTool ------------
|
||||
{
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'demo',
|
||||
description: 'Demo server',
|
||||
command: { kind: 'http', url: baseUrl },
|
||||
},
|
||||
],
|
||||
});
|
||||
const initial = await runtime.connect('demo', { disableOAuth: true });
|
||||
let sum = 0;
|
||||
for (let i = 0; i < INVOCATIONS; i++) {
|
||||
const result = (await runtime.callTool('demo', 'add', {
|
||||
args: { a: i, b: i + 1 },
|
||||
})) as { structuredContent?: { result: number } };
|
||||
sum += result.structuredContent?.result ?? 0;
|
||||
}
|
||||
const afterCalls = await runtime.connect('demo', { disableOAuth: true });
|
||||
const reused = afterCalls === initial;
|
||||
console.log(`[demo] Pattern C — pre-connect(disableOAuth:true) + ${INVOCATIONS} callTool()`);
|
||||
console.log(`[demo] Sum of ${INVOCATIONS} add() results: ${sum}`);
|
||||
console.log(`[demo] Post-callTool connect() === pre-connect ClientContext: ${reused}`);
|
||||
console.log(`[demo] Expected: true (no implicit eviction from callTool internals)`);
|
||||
console.log(`[demo] Result: ${reused ? 'PASS' : 'FAIL'}\n`);
|
||||
await runtime.close();
|
||||
}
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
src/cli.ts
25
src/cli.ts
@ -479,6 +479,7 @@ async function maybeHandleSimpleDaemonFastCall(
|
||||
tool: parsed.tool,
|
||||
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
|
||||
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
|
||||
disableOAuth: parsed.disableOAuth,
|
||||
});
|
||||
const { callResult } = wrapCallResult(result);
|
||||
printCallOutput(callResult, result, parsed.output);
|
||||
@ -583,6 +584,8 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
|
||||
server,
|
||||
includeSchema: options?.includeSchema,
|
||||
autoAuthorize: options?.autoAuthorize,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})) as Awaited<ReturnType<Runtime['listTools']>>,
|
||||
callTool: (server, toolName, options) =>
|
||||
daemonClient.callTool({
|
||||
@ -590,9 +593,27 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
|
||||
tool: toolName,
|
||||
args: options?.args,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
}),
|
||||
listResources: (server, options) => {
|
||||
const params: Record<string, unknown> = { ...options };
|
||||
delete params.allowCachedAuth;
|
||||
delete params.disableOAuth;
|
||||
delete params.oauthSessionOptions;
|
||||
return daemonClient.listResources({
|
||||
server,
|
||||
params,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
});
|
||||
},
|
||||
readResource: (server, uri, options) =>
|
||||
daemonClient.readResource({
|
||||
server,
|
||||
uri,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
}),
|
||||
listResources: (server, options) => daemonClient.listResources({ server, params: options ?? {} }),
|
||||
readResource: (server, uri) => daemonClient.readResource({ server, uri }),
|
||||
connect: async (server) => {
|
||||
throw new Error(`Server '${server}' is only available through daemon request methods.`);
|
||||
},
|
||||
|
||||
@ -25,6 +25,7 @@ export interface CallArgsParseResult {
|
||||
tailLog: boolean;
|
||||
output: OutputFormat;
|
||||
timeoutMs?: number;
|
||||
disableOAuth?: boolean;
|
||||
ephemeral?: EphemeralServerSpec;
|
||||
rawStrings?: boolean;
|
||||
saveImagesDir?: string;
|
||||
@ -59,6 +60,7 @@ const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
||||
'--tool': handleToolFlag,
|
||||
'--timeout': handleTimeoutFlag,
|
||||
'--tail-log': handleTailLogFlag,
|
||||
'--no-oauth': handleDisableOAuthFlag,
|
||||
'--save-images': handleSaveImagesFlag,
|
||||
'--yes': handleNoopFlag,
|
||||
'--raw-strings': handleRawStringsFlag,
|
||||
@ -256,6 +258,11 @@ function handleTailLogFlag(context: FlagHandlerContext): number {
|
||||
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,
|
||||
|
||||
@ -39,6 +39,7 @@ interface PreparedCallRequest extends ResolvedCallTarget {
|
||||
parsed: CallArgsParseResult;
|
||||
hydratedArgs: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
disableOAuth?: boolean;
|
||||
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
||||
}
|
||||
|
||||
@ -66,12 +67,19 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
||||
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
|
||||
const { server, tool } = await resolveServerAndTool(runtime, parsed);
|
||||
|
||||
if (await maybeDescribeServer(runtime, server, tool, parsed.output)) {
|
||||
if (await maybeDescribeServer(runtime, server, tool, parsed.output, parsed.disableOAuth)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
|
||||
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
|
||||
const hydratedArgs = await hydratePositionalArguments(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
parsed.args,
|
||||
parsed.positionalArgs,
|
||||
parsed.disableOAuth
|
||||
);
|
||||
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
|
||||
runtime,
|
||||
server,
|
||||
@ -79,9 +87,18 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
||||
hydratedArgs,
|
||||
parsed.schemaStringCoercionCandidates,
|
||||
parsed.schemaArrayCoercionCandidates,
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
parsed.disableOAuth
|
||||
);
|
||||
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs, ephemeralTarget };
|
||||
return {
|
||||
parsed,
|
||||
server,
|
||||
tool,
|
||||
hydratedArgs: schemaAwareArgs,
|
||||
timeoutMs,
|
||||
disableOAuth: parsed.disableOAuth,
|
||||
ephemeralTarget,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeParsedCallArguments(
|
||||
@ -145,7 +162,7 @@ async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResul
|
||||
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
|
||||
}
|
||||
if (!tool) {
|
||||
tool = await inferSingleToolName(runtime, server);
|
||||
tool = await inferSingleToolName(runtime, server, parsed.disableOAuth);
|
||||
if (!tool) {
|
||||
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
||||
}
|
||||
@ -165,7 +182,8 @@ async function invokePreparedCall(
|
||||
prepared.tool,
|
||||
prepared.hydratedArgs,
|
||||
prepared.timeoutMs,
|
||||
prepared.parsed.output
|
||||
prepared.parsed.output,
|
||||
prepared.disableOAuth
|
||||
);
|
||||
} catch (error) {
|
||||
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
||||
@ -224,11 +242,15 @@ async function maybeDescribeServer(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
tool: string,
|
||||
outputFormat: OutputFormat
|
||||
outputFormat: OutputFormat,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<boolean> {
|
||||
if (tool === 'list_tools') {
|
||||
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
|
||||
const listArgs = [server];
|
||||
if (disableOAuth) {
|
||||
listArgs.push('--no-oauth');
|
||||
}
|
||||
if (outputFormat === 'json') {
|
||||
listArgs.push('--json');
|
||||
}
|
||||
@ -239,7 +261,9 @@ async function maybeDescribeServer(
|
||||
if (tool !== 'help') {
|
||||
return false;
|
||||
}
|
||||
const tools = await runtime.listTools(server, { includeSchema: false, autoAuthorize: false }).catch(() => undefined);
|
||||
const tools = await runtime
|
||||
.listTools(server, { includeSchema: false, autoAuthorize: false, disableOAuth })
|
||||
.catch(() => undefined);
|
||||
if (!tools) {
|
||||
return false;
|
||||
}
|
||||
@ -249,6 +273,9 @@ async function maybeDescribeServer(
|
||||
}
|
||||
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
|
||||
const listArgs = [server];
|
||||
if (disableOAuth) {
|
||||
listArgs.push('--no-oauth');
|
||||
}
|
||||
if (outputFormat === 'json') {
|
||||
listArgs.push('--json');
|
||||
}
|
||||
@ -296,7 +323,8 @@ async function enforceSchemaAwareArgumentTypes(
|
||||
args: Record<string, unknown>,
|
||||
stringCandidates: Record<string, string> | undefined,
|
||||
arrayCandidates: Record<string, string> | undefined,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (
|
||||
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
||||
@ -305,9 +333,10 @@ async function enforceSchemaAwareArgumentTypes(
|
||||
return args;
|
||||
}
|
||||
|
||||
const tools = await withTimeout(loadToolMetadata(runtime, server, { includeSchema: true }), timeoutMs).catch(
|
||||
() => undefined
|
||||
);
|
||||
const tools = await withTimeout(
|
||||
loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }),
|
||||
timeoutMs
|
||||
).catch(() => undefined);
|
||||
if (!tools) {
|
||||
return args;
|
||||
}
|
||||
@ -389,14 +418,15 @@ async function hydratePositionalArguments(
|
||||
server: string,
|
||||
tool: string,
|
||||
namedArgs: Record<string, unknown>,
|
||||
positionalArgs: unknown[] | undefined
|
||||
positionalArgs: unknown[] | undefined,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!positionalArgs || positionalArgs.length === 0) {
|
||||
return namedArgs;
|
||||
}
|
||||
// We need the schema order to know which field each positional argument maps to; pull the
|
||||
// tool list with schemas instead of guessing locally so optional/required order stays correct.
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true }).catch(() => undefined);
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }).catch(() => undefined);
|
||||
if (!tools) {
|
||||
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
||||
}
|
||||
@ -436,9 +466,10 @@ type ToolResolution = IdentifierResolution;
|
||||
|
||||
async function inferSingleToolName(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string
|
||||
server: string,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<string | undefined> {
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth });
|
||||
if (tools.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
@ -456,10 +487,11 @@ async function invokeWithAutoCorrection(
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat
|
||||
outputFormat: OutputFormat,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||
// Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match.
|
||||
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true);
|
||||
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true, disableOAuth);
|
||||
}
|
||||
|
||||
async function attemptCall(
|
||||
@ -469,14 +501,24 @@ async function attemptCall(
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat,
|
||||
allowCorrection: boolean
|
||||
allowCorrection: boolean,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||
try {
|
||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs);
|
||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs, disableOAuth }), timeoutMs);
|
||||
if (allowCorrection && isErrorCallResult(result)) {
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, result);
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, result, disableOAuth);
|
||||
if (resolution) {
|
||||
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
|
||||
const retry = await maybeRetryResolvedTool(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
args,
|
||||
timeoutMs,
|
||||
outputFormat,
|
||||
resolution,
|
||||
disableOAuth
|
||||
);
|
||||
if (retry) {
|
||||
return retry;
|
||||
}
|
||||
@ -497,13 +539,22 @@ async function attemptCall(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, error);
|
||||
const resolution = await maybeResolveToolName(runtime, server, tool, error, disableOAuth);
|
||||
if (!resolution) {
|
||||
maybeReportConnectionIssue(server, tool, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
|
||||
const retry = await maybeRetryResolvedTool(
|
||||
runtime,
|
||||
server,
|
||||
tool,
|
||||
args,
|
||||
timeoutMs,
|
||||
outputFormat,
|
||||
resolution,
|
||||
disableOAuth
|
||||
);
|
||||
if (!retry) {
|
||||
throw error;
|
||||
}
|
||||
@ -518,7 +569,8 @@ async function maybeRetryResolvedTool(
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
outputFormat: OutputFormat,
|
||||
resolution: ToolResolution
|
||||
resolution: ToolResolution,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
entity: 'tool',
|
||||
@ -536,14 +588,15 @@ async function maybeRetryResolvedTool(
|
||||
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
|
||||
emitAutoMessage(dimText(messages.auto));
|
||||
}
|
||||
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false);
|
||||
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false, disableOAuth);
|
||||
}
|
||||
|
||||
async function maybeResolveToolName(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: string,
|
||||
attemptedTool: string,
|
||||
error: unknown
|
||||
error: unknown,
|
||||
disableOAuth: boolean | undefined
|
||||
): Promise<ToolResolution | undefined> {
|
||||
const missingName = extractMissingToolFromError(error);
|
||||
if (!missingName) {
|
||||
@ -555,7 +608,7 @@ async function maybeResolveToolName(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false }).catch(() => undefined);
|
||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth }).catch(() => undefined);
|
||||
if (!tools) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
|
||||
' --timeout <ms> Override the call timeout.',
|
||||
' --output text|markdown|json|raw Control formatting.',
|
||||
' --save-images <dir> Save image content blocks to a directory.',
|
||||
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||
' --raw-strings Keep numeric-looking argument values as strings.',
|
||||
' --no-coerce Keep all key/value and positional arguments as raw strings.',
|
||||
' --tail-log Stream returned log handles.',
|
||||
|
||||
@ -98,7 +98,7 @@ export async function handleList(
|
||||
let completedCount = 0;
|
||||
|
||||
const tasks = servers.map((server, index) =>
|
||||
checkListServer(runtime, server, perServerTimeoutMs).then((result) => {
|
||||
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
|
||||
summaryResults[index] = result;
|
||||
if (renderedResults) {
|
||||
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
||||
@ -190,7 +190,7 @@ export async function handleList(
|
||||
if (flags.statusOnly) {
|
||||
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
||||
try {
|
||||
const result = await checkListServer(runtime, definition, timeoutMs);
|
||||
const result = await checkListServer(runtime, definition, timeoutMs, flags.disableOAuth);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
|
||||
includeSchemas: false,
|
||||
@ -228,6 +228,7 @@ export async function handleList(
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: flags.disableOAuth,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
@ -298,6 +299,7 @@ export async function handleList(
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: flags.disableOAuth,
|
||||
}),
|
||||
timeoutMs
|
||||
),
|
||||
@ -397,12 +399,13 @@ export async function handleList(
|
||||
async function checkListServer(
|
||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||
server: ServerDefinition,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
disableOAuth: boolean
|
||||
): Promise<ListSummaryResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const tools = await withTimeout(
|
||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
|
||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true, disableOAuth }),
|
||||
timeoutMs
|
||||
);
|
||||
return {
|
||||
@ -483,6 +486,7 @@ export function printListHelp(): void {
|
||||
' --verbose Show all config sources for matching servers.',
|
||||
' --sources Include source arrays in JSON output without other verbose details.',
|
||||
' --timeout <ms> Override the per-server discovery timeout.',
|
||||
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||
'',
|
||||
'Examples:',
|
||||
' mcporter list',
|
||||
|
||||
@ -17,6 +17,7 @@ export function extractListFlags(args: string[]): {
|
||||
quiet: boolean;
|
||||
exitCode: boolean;
|
||||
statusOnly: boolean;
|
||||
disableOAuth: boolean;
|
||||
} {
|
||||
let schema = false;
|
||||
let timeoutMs: number | undefined;
|
||||
@ -27,6 +28,7 @@ export function extractListFlags(args: string[]): {
|
||||
let quiet = false;
|
||||
let exitCode = false;
|
||||
let statusOnly = false;
|
||||
let disableOAuth = false;
|
||||
const format = consumeOutputFormat(args, {
|
||||
defaultFormat: 'text',
|
||||
allowed: ['text', 'json'],
|
||||
@ -82,6 +84,11 @@ export function extractListFlags(args: string[]): {
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--no-oauth') {
|
||||
disableOAuth = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
||||
continue;
|
||||
@ -133,5 +140,6 @@ export function extractListFlags(args: string[]): {
|
||||
quiet,
|
||||
exitCode,
|
||||
statusOnly,
|
||||
disableOAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
||||
enableRawShortcut: true,
|
||||
jsonShortcutFlag: '--json',
|
||||
});
|
||||
const disableOAuth = consumeDisableOAuthFlag(args);
|
||||
const server = args.shift();
|
||||
if (!server) {
|
||||
throw new Error('Missing server name. Usage: mcporter resource <server> [uri]');
|
||||
@ -24,7 +25,14 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
|
||||
if (disableOAuth === undefined) {
|
||||
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
|
||||
} else {
|
||||
const connectOptions = { disableOAuth };
|
||||
result = uri
|
||||
? await runtime.readResource(server, uri, connectOptions)
|
||||
: await runtime.listResources(server, connectOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
const issue = analyzeConnectionError(error);
|
||||
if (output === 'json' || output === 'raw') {
|
||||
@ -39,6 +47,20 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
||||
printCallOutput(callResult, result, output);
|
||||
}
|
||||
|
||||
function consumeDisableOAuthFlag(args: string[]): boolean | undefined {
|
||||
let disableOAuth: boolean | undefined;
|
||||
for (let index = 0; index < args.length; ) {
|
||||
const token = args[index];
|
||||
if (token === '--no-oauth') {
|
||||
disableOAuth = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return disableOAuth;
|
||||
}
|
||||
|
||||
export function printResourceHelp(): void {
|
||||
console.error(
|
||||
[
|
||||
@ -51,6 +73,7 @@ export function printResourceHelp(): void {
|
||||
' --output auto|text|markdown|json|raw Choose output rendering.',
|
||||
' --json Shortcut for --output json.',
|
||||
' --raw Shortcut for --output raw.',
|
||||
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||
'',
|
||||
'Examples:',
|
||||
' mcporter resource docs',
|
||||
|
||||
@ -5,6 +5,7 @@ interface LoadToolMetadataOptions {
|
||||
includeSchema?: boolean;
|
||||
autoAuthorize?: boolean;
|
||||
allowCachedAuth?: boolean;
|
||||
disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
||||
@ -13,7 +14,8 @@ function cacheKey(serverName: string, options: LoadToolMetadataOptions): string
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
const allowCachedAuth = options.allowCachedAuth !== false;
|
||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}`;
|
||||
const disableOAuth = options.disableOAuth === true;
|
||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}::disable-oauth:${disableOAuth ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
export async function loadToolMetadata(
|
||||
@ -37,6 +39,7 @@ export async function loadToolMetadata(
|
||||
includeSchema,
|
||||
autoAuthorize,
|
||||
allowCachedAuth: options.allowCachedAuth ?? true,
|
||||
disableOAuth: options.disableOAuth,
|
||||
};
|
||||
const promise = runtime
|
||||
.listTools(serverName, listOptions)
|
||||
|
||||
@ -503,6 +503,13 @@ async function handleSocketRequest(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDaemonDisableOAuth(value: boolean | undefined): boolean {
|
||||
// Daemon messages are independent requests. Omission means the caller did
|
||||
// not request OAuth suppression, so a previous --no-oauth pooled transport
|
||||
// must not make later ordinary calls inherit the no-OAuth posture.
|
||||
return value === true;
|
||||
}
|
||||
|
||||
async function processRequest(
|
||||
rawPayload: string,
|
||||
runtime: Runtime,
|
||||
@ -554,6 +561,7 @@ async function processRequest(
|
||||
const result = await runtime.callTool(params.server, params.tool, {
|
||||
args: params.args ?? {},
|
||||
timeoutMs: params.timeoutMs,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
@ -581,6 +589,7 @@ async function processRequest(
|
||||
includeSchema: params.includeSchema,
|
||||
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
|
||||
allowCachedAuth: params.allowCachedAuth ?? true,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
@ -603,7 +612,11 @@ async function processRequest(
|
||||
logEvent(logContext, `listResources start server=${params.server}`);
|
||||
}
|
||||
try {
|
||||
const result = await runtime.listResources(params.server, params.params);
|
||||
const result = await runtime.listResources(params.server, {
|
||||
...params.params,
|
||||
allowCachedAuth: params.allowCachedAuth,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
logEvent(logContext, `listResources success server=${params.server}`);
|
||||
@ -625,7 +638,10 @@ async function processRequest(
|
||||
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
|
||||
}
|
||||
try {
|
||||
const result = await runtime.readResource(params.server, params.uri);
|
||||
const result = await runtime.readResource(params.server, params.uri, {
|
||||
allowCachedAuth: params.allowCachedAuth,
|
||||
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||
});
|
||||
markActivity(params.server, activity);
|
||||
if (loggable) {
|
||||
logEvent(logContext, `readResource success server=${params.server}`);
|
||||
|
||||
@ -28,6 +28,7 @@ export interface CallToolParams {
|
||||
readonly tool: string;
|
||||
readonly args?: Record<string, unknown>;
|
||||
readonly timeoutMs?: number;
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ListToolsParams {
|
||||
@ -35,16 +36,21 @@ export interface ListToolsParams {
|
||||
readonly includeSchema?: boolean;
|
||||
readonly autoAuthorize?: boolean;
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ListResourcesParams {
|
||||
readonly server: string;
|
||||
readonly params?: Record<string, unknown>;
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ReadResourceParams {
|
||||
readonly server: string;
|
||||
readonly uri: string;
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface CloseServerParams {
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import type { ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ServerDefinition } from '../config.js';
|
||||
import { isKeepAliveServer } from '../lifecycle.js';
|
||||
import type { CallOptions, ListToolsOptions, Runtime } from '../runtime.js';
|
||||
import type {
|
||||
CallOptions,
|
||||
ConnectOptions,
|
||||
ListResourcesOptions,
|
||||
ListToolsOptions,
|
||||
ReadResourceOptions,
|
||||
Runtime,
|
||||
} from '../runtime.js';
|
||||
import type { DaemonClient } from './client.js';
|
||||
|
||||
interface KeepAliveRuntimeOptions {
|
||||
@ -62,6 +68,7 @@ class KeepAliveRuntime implements Runtime {
|
||||
includeSchema: options?.includeSchema,
|
||||
autoAuthorize: options?.autoAuthorize,
|
||||
allowCachedAuth: options?.allowCachedAuth ?? true,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})
|
||||
)) as Awaited<ReturnType<Runtime['listTools']>>;
|
||||
}
|
||||
@ -76,30 +83,45 @@ class KeepAliveRuntime implements Runtime {
|
||||
tool: toolName,
|
||||
args: options?.args,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.base.callTool(server, toolName, options);
|
||||
}
|
||||
|
||||
async listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown> {
|
||||
async listResources(server: string, options?: ListResourcesOptions): Promise<unknown> {
|
||||
if (options?.oauthSessionOptions) {
|
||||
return this.base.listResources(server, options);
|
||||
}
|
||||
const { allowCachedAuth, disableOAuth, ...params } = options ?? {};
|
||||
if (this.shouldUseDaemon(server)) {
|
||||
return this.invokeWithRestart(server, 'listResources', () =>
|
||||
this.daemon.listResources({ server, params: options ?? {} })
|
||||
this.daemon.listResources({ server, params, allowCachedAuth, disableOAuth })
|
||||
);
|
||||
}
|
||||
return this.base.listResources(server, options);
|
||||
}
|
||||
|
||||
async readResource(server: string, uri: string): Promise<unknown> {
|
||||
if (this.shouldUseDaemon(server)) {
|
||||
return this.invokeWithRestart(server, 'readResource', () => this.daemon.readResource({ server, uri }));
|
||||
async readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown> {
|
||||
if (options?.oauthSessionOptions) {
|
||||
return this.base.readResource(server, uri, options);
|
||||
}
|
||||
return this.base.readResource(server, uri);
|
||||
if (this.shouldUseDaemon(server)) {
|
||||
return this.invokeWithRestart(server, 'readResource', () =>
|
||||
this.daemon.readResource({
|
||||
server,
|
||||
uri,
|
||||
allowCachedAuth: options?.allowCachedAuth,
|
||||
disableOAuth: options?.disableOAuth,
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.base.readResource(server, uri, options);
|
||||
}
|
||||
|
||||
async connect(server: string): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||
return this.base.connect(server);
|
||||
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||
return this.base.connect(server, options);
|
||||
}
|
||||
|
||||
async close(server?: string): Promise<void> {
|
||||
|
||||
@ -4,7 +4,10 @@ export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.j
|
||||
export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js';
|
||||
export type {
|
||||
CallOptions,
|
||||
ConnectOptions,
|
||||
ListResourcesOptions,
|
||||
ListToolsOptions,
|
||||
ReadResourceOptions,
|
||||
Runtime,
|
||||
RuntimeLogger,
|
||||
RuntimeOptions,
|
||||
|
||||
624
src/runtime.ts
624
src/runtime.ts
@ -18,6 +18,14 @@ export { MCPORTER_VERSION } from './version.js';
|
||||
const PACKAGE_NAME = 'mcporter';
|
||||
const OAUTH_CODE_TIMEOUT_MS = resolveOAuthTimeoutFromEnv();
|
||||
|
||||
type CachedClientEntry = {
|
||||
readonly server: string;
|
||||
readonly promise: Promise<ClientContext>;
|
||||
readonly contextPromise?: Promise<ClientContext>;
|
||||
readonly allowCachedAuth: boolean | undefined;
|
||||
readonly disableOAuth: boolean;
|
||||
};
|
||||
|
||||
export interface RuntimeOptions {
|
||||
readonly configPath?: string;
|
||||
readonly servers?: ServerDefinition[];
|
||||
@ -35,6 +43,11 @@ export type RuntimeLogger = Logger;
|
||||
export interface CallOptions {
|
||||
readonly args?: CallToolRequest['params']['arguments'];
|
||||
readonly timeoutMs?: number;
|
||||
/**
|
||||
* Suppress interactive OAuth for this call while still allowing cached
|
||||
* bearer tokens to be applied. Intended for headless callers.
|
||||
*/
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ListToolsOptions {
|
||||
@ -42,13 +55,49 @@ export interface ListToolsOptions {
|
||||
readonly autoAuthorize?: boolean;
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||
/**
|
||||
* Suppress interactive OAuth for this listing while keeping the connection
|
||||
* cache available. Prefer this over `autoAuthorize: false` for long-running
|
||||
* headless callers that need cached-token-only behavior.
|
||||
*/
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
interface ConnectOptions {
|
||||
export type ListResourcesOptions = Partial<ListResourcesRequest['params']> & {
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||
readonly disableOAuth?: boolean;
|
||||
};
|
||||
|
||||
export interface ReadResourceOptions {
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectOptions {
|
||||
readonly maxOAuthAttempts?: number;
|
||||
readonly skipCache?: boolean;
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||
/**
|
||||
* When `true`, never start an OAuth flow for this server — equivalent
|
||||
* to `maxOAuthAttempts: 0` for the purpose of avoiding interactive
|
||||
* authorization. Unlike `maxOAuthAttempts: 0`, callers passing
|
||||
* `disableOAuth: true` participate in connection caching: repeated
|
||||
* `connect()` / `callTool()` / `listTools()` calls reuse the same
|
||||
* `ClientContext`, and `close()` reaps it.
|
||||
*
|
||||
* Intended for long-running headless callers (daemons, scheduled jobs,
|
||||
* CI workers) that have no browser and must rely on cached tokens.
|
||||
*
|
||||
* Cache identity: clients established with `disableOAuth: true` are
|
||||
* stored in their own cache slot — sharing with a connection that
|
||||
* could refresh into an OAuth flow would violate the no-browser-launch
|
||||
* guarantee. Switching the flag between calls keeps both variants cached
|
||||
* until the caller closes the server or runtime.
|
||||
*/
|
||||
readonly disableOAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
@ -59,9 +108,9 @@ export interface Runtime {
|
||||
getInstructions?(server: string): Promise<string | undefined>;
|
||||
listTools(server: string, options?: ListToolsOptions): Promise<ServerToolInfo[]>;
|
||||
callTool(server: string, toolName: string, options?: CallOptions): Promise<unknown>;
|
||||
listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown>;
|
||||
readResource(server: string, uri: string): Promise<unknown>;
|
||||
connect(server: string): Promise<ClientContext>;
|
||||
listResources(server: string, options?: ListResourcesOptions): Promise<unknown>;
|
||||
readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown>;
|
||||
connect(server: string, options?: ConnectOptions): Promise<ClientContext>;
|
||||
close(server?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@ -92,11 +141,13 @@ export async function callOnce(params: {
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
configPath?: string;
|
||||
disableOAuth?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const runtime = await createRuntime({ configPath: params.configPath });
|
||||
try {
|
||||
return await runtime.callTool(params.server, params.toolName, {
|
||||
args: params.args,
|
||||
disableOAuth: params.disableOAuth,
|
||||
});
|
||||
} finally {
|
||||
await runtime.close(params.server);
|
||||
@ -105,13 +156,13 @@ export async function callOnce(params: {
|
||||
|
||||
class McpRuntime implements Runtime {
|
||||
private readonly definitions: Map<string, ServerDefinition>;
|
||||
private readonly clients = new Map<
|
||||
string,
|
||||
{
|
||||
readonly promise: Promise<ClientContext>;
|
||||
readonly allowCachedAuth: boolean | undefined;
|
||||
}
|
||||
>();
|
||||
private readonly clients = new Map<string, CachedClientEntry>();
|
||||
private readonly activeClientKeys = new Map<string, string>();
|
||||
private readonly contextCacheKeys = new WeakMap<ClientContext, string>();
|
||||
private readonly contextCachePromises = new WeakMap<ClientContext, Promise<ClientContext>>();
|
||||
private readonly connectionSetupTails = new Map<string, Promise<void>>();
|
||||
private readonly serverGenerations = new Map<string, number>();
|
||||
private readonly retirementPromises = new Map<string, Set<Promise<void>>>();
|
||||
private readonly logger: RuntimeLogger;
|
||||
private readonly clientInfo: { name: string; version: string };
|
||||
private readonly oauthTimeoutMs?: number;
|
||||
@ -162,12 +213,15 @@ class McpRuntime implements Runtime {
|
||||
if (!options.overwrite && this.definitions.has(definition.name)) {
|
||||
throw new Error(`MCP server '${definition.name}' already exists.`);
|
||||
}
|
||||
this.bumpServerGeneration(definition.name);
|
||||
this.definitions.set(definition.name, definition);
|
||||
this.clients.delete(definition.name);
|
||||
this.retireCachedEntriesForServer(definition.name);
|
||||
}
|
||||
|
||||
async getInstructions(server: string): Promise<string | undefined> {
|
||||
const cached = this.clients.get(server.trim());
|
||||
const active = this.activeClientForServer(server);
|
||||
const fallbackEntries = active ? [] : this.cachedEntriesForServer(server);
|
||||
const cached = active ?? (fallbackEntries.length === 1 ? fallbackEntries[0] : undefined);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
@ -188,12 +242,23 @@ class McpRuntime implements Runtime {
|
||||
// listTools queries tool metadata and optionally includes schemas when requested.
|
||||
async listTools(server: string, options: ListToolsOptions = {}): Promise<ServerToolInfo[]> {
|
||||
// Toggle auto authorization so list can run without forcing OAuth flows.
|
||||
// `disableOAuth` is the cache-friendly suppression path; when present it
|
||||
// supersedes the legacy `autoAuthorize: false` uncached behavior.
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||
const allowCachedAuth = this.effectiveAllowCachedAuthForOperation(
|
||||
server,
|
||||
options.allowCachedAuth,
|
||||
disableOAuth,
|
||||
true
|
||||
);
|
||||
const useLegacyNoAuthorize = !autoAuthorize && disableOAuth !== true;
|
||||
const context = await this.connect(server, {
|
||||
maxOAuthAttempts: autoAuthorize ? undefined : 0,
|
||||
skipCache: !autoAuthorize,
|
||||
allowCachedAuth: options.allowCachedAuth ?? true,
|
||||
maxOAuthAttempts: useLegacyNoAuthorize ? 0 : undefined,
|
||||
skipCache: useLegacyNoAuthorize,
|
||||
allowCachedAuth,
|
||||
oauthSessionOptions: options.oauthSessionOptions,
|
||||
disableOAuth,
|
||||
});
|
||||
let closeError: unknown;
|
||||
const tools: ServerToolInfo[] = [];
|
||||
@ -214,10 +279,10 @@ class McpRuntime implements Runtime {
|
||||
} catch (error) {
|
||||
// Keep-alive STDIO transports often die when Chrome closes; drop the cached client
|
||||
// so the next call spins up a fresh process instead of reusing the broken handle.
|
||||
await this.resetConnectionOnError(server, error);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!autoAuthorize) {
|
||||
if (useLegacyNoAuthorize) {
|
||||
try {
|
||||
await this.closeContext(context);
|
||||
} catch (error) {
|
||||
@ -240,10 +305,14 @@ class McpRuntime implements Runtime {
|
||||
`Tool '${toolName}' is not accessible on server '${definition.name}' (blocked by configuration).`
|
||||
);
|
||||
}
|
||||
let context: ClientContext | undefined;
|
||||
try {
|
||||
const { client } = await this.connect(server, {
|
||||
allowCachedAuth: true,
|
||||
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||
context = await this.connect(server, {
|
||||
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(server, undefined, disableOAuth, true),
|
||||
disableOAuth,
|
||||
});
|
||||
const { client } = context;
|
||||
const params: CallToolRequest['params'] = {
|
||||
name: toolName,
|
||||
arguments: options.args ?? {},
|
||||
@ -264,102 +333,379 @@ class McpRuntime implements Runtime {
|
||||
} catch (error) {
|
||||
// Runtime timeouts and transport crashes should tear down the cached connection so
|
||||
// the daemon (or direct runtime) can relaunch the MCP server on the next attempt.
|
||||
await this.resetConnectionOnError(server, error);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// listResources delegates to the MCP resources/list method with passthrough params.
|
||||
async listResources(server: string, options: Partial<ListResourcesRequest['params']> = {}): Promise<unknown> {
|
||||
async listResources(server: string, options: ListResourcesOptions = {}): Promise<unknown> {
|
||||
const { allowCachedAuth, disableOAuth, oauthSessionOptions, ...params } = options;
|
||||
let context: ClientContext | undefined;
|
||||
try {
|
||||
const { client } = await this.connect(server);
|
||||
return await client.listResources(options as ListResourcesRequest['params']);
|
||||
const effectiveDisableOAuth = this.effectiveDisableOAuthForOperation(server, disableOAuth);
|
||||
context = await this.connect(server, {
|
||||
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(
|
||||
server,
|
||||
allowCachedAuth,
|
||||
effectiveDisableOAuth,
|
||||
undefined
|
||||
),
|
||||
oauthSessionOptions,
|
||||
disableOAuth: effectiveDisableOAuth,
|
||||
});
|
||||
const { client } = context;
|
||||
return await client.listResources(params as ListResourcesRequest['params']);
|
||||
} catch (error) {
|
||||
// Fatal listResources errors usually mean the underlying transport has gone away.
|
||||
await this.resetConnectionOnError(server, error);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readResource(server: string, uri: string): Promise<unknown> {
|
||||
async readResource(server: string, uri: string, options: ReadResourceOptions = {}): Promise<unknown> {
|
||||
let context: ClientContext | undefined;
|
||||
try {
|
||||
const { client } = await this.connect(server);
|
||||
const effectiveDisableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||
context = await this.connect(server, {
|
||||
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(
|
||||
server,
|
||||
options.allowCachedAuth,
|
||||
effectiveDisableOAuth,
|
||||
undefined
|
||||
),
|
||||
oauthSessionOptions: options.oauthSessionOptions,
|
||||
disableOAuth: effectiveDisableOAuth,
|
||||
});
|
||||
const { client } = context;
|
||||
return await client.readResource({ uri } satisfies ReadResourceRequest['params']);
|
||||
} catch (error) {
|
||||
await this.resetConnectionOnError(server, error);
|
||||
await this.resetConnectionOnError(server, error, context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private effectiveDisableOAuthForOperation(server: string, requested: boolean | undefined): boolean | undefined {
|
||||
if (requested !== undefined) {
|
||||
return requested;
|
||||
}
|
||||
const cached = this.cachedEntriesForServer(server);
|
||||
const active = this.activeClientForServer(server);
|
||||
if (active) {
|
||||
return active.disableOAuth;
|
||||
}
|
||||
if (cached.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const [first] = cached;
|
||||
return cached.every((entry) => entry.disableOAuth === first?.disableOAuth) ? first?.disableOAuth : undefined;
|
||||
}
|
||||
|
||||
private effectiveAllowCachedAuthForOperation(
|
||||
server: string,
|
||||
requested: boolean | undefined,
|
||||
disableOAuth: boolean | undefined,
|
||||
defaultValue: boolean | undefined
|
||||
): boolean | undefined {
|
||||
if (requested !== undefined) {
|
||||
return requested;
|
||||
}
|
||||
if (disableOAuth !== true) {
|
||||
return defaultValue;
|
||||
}
|
||||
const active = this.activeClientForServer(server);
|
||||
if (active?.disableOAuth === true) {
|
||||
return active.allowCachedAuth;
|
||||
}
|
||||
const cached = this.cachedEntriesForServer(server).filter((entry) => entry.disableOAuth);
|
||||
return cached.length === 1 ? cached[0]?.allowCachedAuth : defaultValue;
|
||||
}
|
||||
|
||||
private cachedEntriesForServer(server: string): CachedClientEntry[] {
|
||||
const normalized = server.trim();
|
||||
return [...this.clients.values()].filter((entry) => entry.server === normalized);
|
||||
}
|
||||
|
||||
private retireCachedEntriesForServer(server: string): void {
|
||||
const normalized = server.trim();
|
||||
const retired: CachedClientEntry[] = [];
|
||||
for (const [key, cached] of this.clients.entries()) {
|
||||
if (cached.server === normalized) {
|
||||
this.clients.delete(key);
|
||||
retired.push(cached);
|
||||
}
|
||||
}
|
||||
this.activeClientKeys.delete(normalized);
|
||||
if (retired.length > 0) {
|
||||
const retirement = this.trackRetirement(normalized, this.closeCachedEntries(retired));
|
||||
void retirement.catch((error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to close retired '${normalized}' connection: ${detail}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private activeClientForServer(server: string): CachedClientEntry | undefined {
|
||||
const normalized = server.trim();
|
||||
const activeKey = this.activeClientKeys.get(normalized);
|
||||
if (!activeKey) {
|
||||
return undefined;
|
||||
}
|
||||
const active = this.clients.get(activeKey);
|
||||
return active?.server === normalized ? active : undefined;
|
||||
}
|
||||
|
||||
private serverGeneration(server: string): number {
|
||||
return this.serverGenerations.get(server.trim()) ?? 0;
|
||||
}
|
||||
|
||||
private bumpServerGeneration(server: string): void {
|
||||
const normalized = server.trim();
|
||||
this.serverGenerations.set(normalized, this.serverGeneration(normalized) + 1);
|
||||
}
|
||||
|
||||
private bumpAllServerGenerations(): void {
|
||||
const servers = new Set<string>([
|
||||
...this.definitions.keys(),
|
||||
...[...this.clients.values()].map((entry) => entry.server),
|
||||
...this.connectionSetupTails.keys(),
|
||||
]);
|
||||
for (const server of servers) {
|
||||
this.bumpServerGeneration(server);
|
||||
}
|
||||
}
|
||||
|
||||
// connect lazily instantiates a client context per server and memoizes it.
|
||||
async connect(server: string, options: ConnectOptions = {}): Promise<ClientContext> {
|
||||
// Reuse cached connections unless the caller explicitly opted out.
|
||||
const normalized = server.trim();
|
||||
|
||||
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
|
||||
|
||||
if (useCache) {
|
||||
const existing = this.clients.get(normalized);
|
||||
if (existing) {
|
||||
if (existing.allowCachedAuth === options.allowCachedAuth || options.allowCachedAuth === undefined) {
|
||||
return existing.promise;
|
||||
}
|
||||
await this.close(normalized).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const definition = this.definitions.get(normalized);
|
||||
let definition = this.definitions.get(normalized);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||
}
|
||||
const generation = this.serverGeneration(normalized);
|
||||
|
||||
const connection = createClientContext(definition, this.logger, this.clientInfo, {
|
||||
// `maxOAuthAttempts: 0` keeps its legacy escape-the-cache contract.
|
||||
// `disableOAuth: true` is the cache-friendly OAuth-suppression knob:
|
||||
// it disables the interactive OAuth flow at the transport layer but
|
||||
// participates in caching (own slot, see the eviction rule below).
|
||||
const disableOAuth = options.disableOAuth === true;
|
||||
// Normalize: a caller asking for `disableOAuth: true` has no path to
|
||||
// OAuth, so cached-token application is the only auth they can ever
|
||||
// use — default `allowCachedAuth: true` when the caller didn't pick
|
||||
// a side. Without this, the documented headless setup
|
||||
// `connect(server, { disableOAuth: true })` stored
|
||||
// `allowCachedAuth: undefined`, and the next internal `callTool` /
|
||||
// `listTools` (which force `allowCachedAuth: true`) immediately
|
||||
// evicted and reopened the transport. Explicit `false` is honored
|
||||
// (header-only / anonymous callers).
|
||||
const effectiveAllowCachedAuth = options.allowCachedAuth ?? (disableOAuth ? true : undefined);
|
||||
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
|
||||
let ignoresAuthCachePolicy = this.ignoresAuthCachePolicy(definition);
|
||||
let cacheAllowCachedAuth = ignoresAuthCachePolicy ? undefined : effectiveAllowCachedAuth;
|
||||
let cacheDisableOAuth = ignoresAuthCachePolicy ? false : disableOAuth;
|
||||
let cacheKey = this.cacheKey(normalized, cacheAllowCachedAuth, cacheDisableOAuth);
|
||||
|
||||
if (useCache) {
|
||||
const existing = this.findCachedEntryForRequest(
|
||||
normalized,
|
||||
definition,
|
||||
ignoresAuthCachePolicy ? undefined : options.allowCachedAuth,
|
||||
cacheAllowCachedAuth,
|
||||
cacheDisableOAuth
|
||||
);
|
||||
if (existing) {
|
||||
const [existingKey, cached] = existing;
|
||||
const activeEntry = ignoresAuthCachePolicy
|
||||
? {
|
||||
...cached,
|
||||
allowCachedAuth: effectiveAllowCachedAuth,
|
||||
disableOAuth,
|
||||
}
|
||||
: cached;
|
||||
if (activeEntry !== cached) {
|
||||
this.clients.set(existingKey, activeEntry);
|
||||
}
|
||||
this.activeClientKeys.set(normalized, existingKey);
|
||||
return activeEntry.promise;
|
||||
}
|
||||
}
|
||||
|
||||
let releaseConnectionSetup: (() => void) | undefined;
|
||||
if (useCache && this.shouldSerializeConnectionSetup(definition, disableOAuth)) {
|
||||
releaseConnectionSetup = await this.enterConnectionSetup(normalized);
|
||||
try {
|
||||
if (this.serverGeneration(normalized) !== generation) {
|
||||
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
|
||||
}
|
||||
const refreshedDefinition = this.definitions.get(normalized);
|
||||
if (!refreshedDefinition) {
|
||||
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||
}
|
||||
definition = refreshedDefinition;
|
||||
ignoresAuthCachePolicy = this.ignoresAuthCachePolicy(definition);
|
||||
cacheAllowCachedAuth = ignoresAuthCachePolicy ? undefined : effectiveAllowCachedAuth;
|
||||
cacheDisableOAuth = ignoresAuthCachePolicy ? false : disableOAuth;
|
||||
cacheKey = this.cacheKey(normalized, cacheAllowCachedAuth, cacheDisableOAuth);
|
||||
const existing = this.findCachedEntryForRequest(
|
||||
normalized,
|
||||
definition,
|
||||
ignoresAuthCachePolicy ? undefined : options.allowCachedAuth,
|
||||
cacheAllowCachedAuth,
|
||||
cacheDisableOAuth
|
||||
);
|
||||
if (existing) {
|
||||
releaseConnectionSetup();
|
||||
releaseConnectionSetup = undefined;
|
||||
const [existingKey, cached] = existing;
|
||||
this.activeClientKeys.set(normalized, existingKey);
|
||||
return cached.promise;
|
||||
}
|
||||
await this.retireConflictingOAuthEntries(normalized, cacheKey);
|
||||
if (this.serverGeneration(normalized) !== generation) {
|
||||
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
|
||||
}
|
||||
const latestDefinition = this.definitions.get(normalized);
|
||||
if (!latestDefinition) {
|
||||
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||
}
|
||||
definition = latestDefinition;
|
||||
} catch (error) {
|
||||
releaseConnectionSetup?.();
|
||||
releaseConnectionSetup = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let connectionDefinition = definition;
|
||||
let contextPromise = createClientContext(definition, this.logger, this.clientInfo, {
|
||||
maxOAuthAttempts: options.maxOAuthAttempts,
|
||||
oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS,
|
||||
onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted),
|
||||
allowCachedAuth: options.allowCachedAuth,
|
||||
onDefinitionPromoted: (promoted) => {
|
||||
if (
|
||||
this.serverGeneration(normalized) === generation &&
|
||||
this.definitions.get(normalized) === connectionDefinition
|
||||
) {
|
||||
this.definitions.set(promoted.name, promoted);
|
||||
connectionDefinition = promoted;
|
||||
}
|
||||
},
|
||||
allowCachedAuth: effectiveAllowCachedAuth,
|
||||
oauthSessionOptions: options.oauthSessionOptions,
|
||||
disableOAuth,
|
||||
recordPath: this.recordPath,
|
||||
replayPath: this.replayPath,
|
||||
});
|
||||
|
||||
if (useCache) {
|
||||
this.clients.set(normalized, { promise: connection, allowCachedAuth: options.allowCachedAuth });
|
||||
const previousActiveKey = this.activeClientKeys.get(normalized);
|
||||
contextPromise = contextPromise.then((context) => {
|
||||
this.contextCacheKeys.set(context, cacheKey);
|
||||
this.contextCachePromises.set(context, contextPromise);
|
||||
return context;
|
||||
});
|
||||
let connection!: Promise<ClientContext>;
|
||||
connection = contextPromise.then((context) => {
|
||||
const stillCached = this.clients.get(cacheKey)?.promise === connection;
|
||||
if (this.serverGeneration(normalized) !== generation || !stillCached) {
|
||||
this.contextCacheKeys.delete(context);
|
||||
this.contextCachePromises.delete(context);
|
||||
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
|
||||
}
|
||||
return context;
|
||||
});
|
||||
this.activeClientKeys.set(normalized, cacheKey);
|
||||
this.clients.set(cacheKey, {
|
||||
server: normalized,
|
||||
promise: connection,
|
||||
contextPromise,
|
||||
allowCachedAuth: ignoresAuthCachePolicy ? effectiveAllowCachedAuth : cacheAllowCachedAuth,
|
||||
disableOAuth: ignoresAuthCachePolicy ? disableOAuth : cacheDisableOAuth,
|
||||
});
|
||||
try {
|
||||
return await connection;
|
||||
} catch (error) {
|
||||
this.clients.delete(normalized);
|
||||
const ownsCacheEntry = this.clients.get(cacheKey)?.promise === connection;
|
||||
if (ownsCacheEntry) {
|
||||
this.clients.delete(cacheKey);
|
||||
if (
|
||||
this.activeClientKeys.get(normalized) === cacheKey &&
|
||||
previousActiveKey &&
|
||||
this.clients.has(previousActiveKey)
|
||||
) {
|
||||
this.activeClientKeys.set(normalized, previousActiveKey);
|
||||
} else if (
|
||||
this.activeClientKeys.get(normalized) === cacheKey ||
|
||||
this.cachedEntriesForServer(normalized).length === 0
|
||||
) {
|
||||
this.activeClientKeys.delete(normalized);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
releaseConnectionSetup?.();
|
||||
}
|
||||
}
|
||||
|
||||
return connection;
|
||||
releaseConnectionSetup?.();
|
||||
return contextPromise;
|
||||
}
|
||||
|
||||
// close tears down transports (and OAuth sessions) for a single server or all servers.
|
||||
async close(server?: string): Promise<void> {
|
||||
if (server) {
|
||||
const normalized = server.trim();
|
||||
const cached = this.clients.get(normalized);
|
||||
if (!cached) {
|
||||
return;
|
||||
this.bumpServerGeneration(normalized);
|
||||
const entries = [...this.clients.entries()].filter(([, cached]) => cached.server === normalized);
|
||||
if (entries.length === 0) {
|
||||
this.activeClientKeys.delete(normalized);
|
||||
}
|
||||
const context = await cached.promise;
|
||||
try {
|
||||
await this.closeContext(context);
|
||||
} finally {
|
||||
this.clients.delete(normalized);
|
||||
for (const [key] of entries) {
|
||||
this.clients.delete(key);
|
||||
}
|
||||
this.activeClientKeys.delete(normalized);
|
||||
if (entries.length > 0) {
|
||||
void this.trackRetirement(normalized, this.closeCachedEntries(entries.map(([, cached]) => cached)));
|
||||
}
|
||||
await this.awaitRetirements(normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, cached] of this.clients.entries()) {
|
||||
try {
|
||||
const context = await cached.promise;
|
||||
await this.closeContext(context);
|
||||
} finally {
|
||||
this.clients.delete(name);
|
||||
}
|
||||
this.bumpAllServerGenerations();
|
||||
const entries = [...this.clients.entries()];
|
||||
this.clients.clear();
|
||||
this.activeClientKeys.clear();
|
||||
const byServer = new Map<string, CachedClientEntry[]>();
|
||||
for (const [, cached] of entries) {
|
||||
const serverEntries = byServer.get(cached.server) ?? [];
|
||||
serverEntries.push(cached);
|
||||
byServer.set(cached.server, serverEntries);
|
||||
}
|
||||
for (const [serverName, serverEntries] of byServer) {
|
||||
void this.trackRetirement(serverName, this.closeCachedEntries(serverEntries));
|
||||
}
|
||||
await this.awaitRetirements();
|
||||
}
|
||||
|
||||
private contextPromiseFor(cached: CachedClientEntry): Promise<ClientContext> {
|
||||
return cached.contextPromise ?? cached.promise;
|
||||
}
|
||||
|
||||
private async closeCachedEntries(entries: CachedClientEntry[]): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(async (cached) => {
|
||||
const context = await this.contextPromiseFor(cached);
|
||||
try {
|
||||
await this.closeContext(context);
|
||||
} finally {
|
||||
this.contextCacheKeys.delete(context);
|
||||
this.contextCachePromises.delete(context);
|
||||
}
|
||||
})
|
||||
);
|
||||
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
if (firstFailure) {
|
||||
throw firstFailure.reason;
|
||||
}
|
||||
}
|
||||
|
||||
@ -392,23 +738,165 @@ class McpRuntime implements Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
private async resetConnectionOnError(server: string, error: unknown): Promise<void> {
|
||||
private async resetConnectionOnError(server: string, error: unknown, failedContext?: ClientContext): Promise<void> {
|
||||
if (!shouldResetConnection(error)) {
|
||||
return;
|
||||
}
|
||||
const normalized = server.trim();
|
||||
if (!this.clients.has(normalized)) {
|
||||
if (!failedContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Reuse the existing close() helper so transport shutdown stays consistent with
|
||||
// normal runtime disposal (wait for STDIO children, close OAuth sessions, etc.).
|
||||
await this.close(normalized);
|
||||
const failedKey = this.contextCacheKeys.get(failedContext);
|
||||
const failedEntry = failedKey ? this.clients.get(failedKey) : undefined;
|
||||
const failedContextPromise = this.contextCachePromises.get(failedContext);
|
||||
if (
|
||||
!failedKey ||
|
||||
failedEntry?.server !== normalized ||
|
||||
!failedContextPromise ||
|
||||
this.contextPromiseFor(failedEntry) !== failedContextPromise
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.clients.get(failedKey)?.promise !== failedEntry.promise) {
|
||||
return;
|
||||
}
|
||||
this.clients.delete(failedKey);
|
||||
if (this.activeClientKeys.get(normalized) === failedKey || this.cachedEntriesForServer(normalized).length === 0) {
|
||||
this.activeClientKeys.delete(normalized);
|
||||
}
|
||||
try {
|
||||
await this.closeContext(failedContext);
|
||||
} finally {
|
||||
this.contextCacheKeys.delete(failedContext);
|
||||
this.contextCachePromises.delete(failedContext);
|
||||
}
|
||||
} catch (closeError) {
|
||||
const detail = closeError instanceof Error ? closeError.message : String(closeError);
|
||||
this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
private findCachedEntryForRequest(
|
||||
server: string,
|
||||
definition: ServerDefinition,
|
||||
requestedAllowCachedAuth: boolean | undefined,
|
||||
effectiveAllowCachedAuth: boolean | undefined,
|
||||
disableOAuth: boolean
|
||||
): [string, CachedClientEntry] | undefined {
|
||||
const exactKey = this.cacheKey(server, effectiveAllowCachedAuth, disableOAuth);
|
||||
if (this.ignoresAuthCachePolicy(definition)) {
|
||||
const exact = this.clients.get(exactKey);
|
||||
return exact ? [exactKey, exact] : undefined;
|
||||
}
|
||||
if (requestedAllowCachedAuth !== undefined) {
|
||||
const exact = this.clients.get(exactKey);
|
||||
return exact ? [exactKey, exact] : undefined;
|
||||
}
|
||||
|
||||
const activeKey = this.activeClientKeys.get(server);
|
||||
const active = activeKey ? this.clients.get(activeKey) : undefined;
|
||||
const policyMatches = (cached: CachedClientEntry) =>
|
||||
effectiveAllowCachedAuth === undefined || cached.allowCachedAuth === effectiveAllowCachedAuth;
|
||||
if (activeKey && active?.server === server && active.disableOAuth === disableOAuth && policyMatches(active)) {
|
||||
return [activeKey, active];
|
||||
}
|
||||
|
||||
const matches = [...this.clients.entries()].filter(
|
||||
([, cached]) => cached.server === server && cached.disableOAuth === disableOAuth && policyMatches(cached)
|
||||
);
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
const exact = this.clients.get(exactKey);
|
||||
return exact ? [exactKey, exact] : undefined;
|
||||
}
|
||||
|
||||
private async retireConflictingOAuthEntries(server: string, keepKey: string): Promise<void> {
|
||||
const conflicting = [...this.clients.entries()].filter(
|
||||
([key, cached]) => key !== keepKey && cached.server === server && !cached.disableOAuth
|
||||
);
|
||||
if (conflicting.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const [key] of conflicting) {
|
||||
this.clients.delete(key);
|
||||
if (this.activeClientKeys.get(server) === key) {
|
||||
this.activeClientKeys.delete(server);
|
||||
}
|
||||
}
|
||||
await this.trackRetirement(server, this.closeCachedEntries(conflicting.map(([, cached]) => cached)));
|
||||
}
|
||||
|
||||
private shouldSerializeConnectionSetup(definition: ServerDefinition, disableOAuth: boolean): boolean {
|
||||
return definition.command.kind === 'http' && !disableOAuth && !this.ignoresAuthCachePolicy(definition);
|
||||
}
|
||||
|
||||
private ignoresAuthCachePolicy(definition: ServerDefinition): boolean {
|
||||
const replayServer = process.env.MCPORTER_REPLAY_SERVER;
|
||||
const replaysDefinition = Boolean(this.replayPath) && (!replayServer || replayServer === definition.name);
|
||||
return definition.command.kind === 'stdio' || replaysDefinition;
|
||||
}
|
||||
|
||||
private trackRetirement(server: string, retirement: Promise<void>): Promise<void> {
|
||||
const pending = this.retirementPromises.get(server) ?? new Set<Promise<void>>();
|
||||
pending.add(retirement);
|
||||
this.retirementPromises.set(server, pending);
|
||||
const cleanup = () => {
|
||||
pending.delete(retirement);
|
||||
if (pending.size === 0) {
|
||||
this.retirementPromises.delete(server);
|
||||
}
|
||||
};
|
||||
retirement.then(cleanup, cleanup);
|
||||
return retirement;
|
||||
}
|
||||
|
||||
private async awaitRetirements(server?: string): Promise<void> {
|
||||
const pending = server ? [...(this.retirementPromises.get(server) ?? [])] : [];
|
||||
if (!server) {
|
||||
for (const retirements of this.retirementPromises.values()) {
|
||||
pending.push(...retirements);
|
||||
}
|
||||
}
|
||||
const results = await Promise.allSettled(pending);
|
||||
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
if (firstFailure) {
|
||||
throw firstFailure.reason;
|
||||
}
|
||||
}
|
||||
|
||||
private async enterConnectionSetup(server: string): Promise<() => void> {
|
||||
const previous = this.connectionSetupTails.get(server) ?? Promise.resolve();
|
||||
let releaseCurrent!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseCurrent = resolve;
|
||||
});
|
||||
const tail = previous.catch(() => {}).then(() => current);
|
||||
this.connectionSetupTails.set(server, tail);
|
||||
await previous.catch(() => {});
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
releaseCurrent();
|
||||
void tail.finally(() => {
|
||||
if (this.connectionSetupTails.get(server) === tail) {
|
||||
this.connectionSetupTails.delete(server);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private cacheKey(server: string, allowCachedAuth: boolean | undefined, disableOAuth: boolean): string {
|
||||
const cachedAuthKey =
|
||||
allowCachedAuth === true ? 'cached-auth-on' : allowCachedAuth === false ? 'cached-auth-off' : 'cached-auth-unset';
|
||||
return `${server}\u0000oauth-disabled:${disableOAuth ? '1' : '0'}\u0000${cachedAuthKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
// createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL.
|
||||
|
||||
@ -86,6 +86,14 @@ export interface CreateClientContextOptions {
|
||||
readonly onDefinitionPromoted?: (definition: ServerDefinition) => void;
|
||||
readonly allowCachedAuth?: boolean;
|
||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||
/**
|
||||
* When `true`, suppress the interactive OAuth flow entirely. See
|
||||
* `ConnectOptions.disableOAuth` in `runtime.ts` for the caller-facing
|
||||
* semantics. Internally this short-circuits `shouldEstablishOAuth` and
|
||||
* `maybePromoteHttpDefinition` so the unauthorized-fallback path
|
||||
* cannot re-enable OAuth on a daemon-shaped caller.
|
||||
*/
|
||||
readonly disableOAuth?: boolean;
|
||||
readonly recordPath?: string;
|
||||
readonly replayPath?: string;
|
||||
}
|
||||
@ -188,7 +196,11 @@ function maybePromoteHttpDefinition(
|
||||
logger: Logger,
|
||||
options: CreateClientContextOptions
|
||||
): ServerDefinition | undefined {
|
||||
if (options.maxOAuthAttempts === 0) {
|
||||
// Both flags suppress promotion-to-OAuth on a 401 fallback. Without
|
||||
// this guard, a daemon-mode caller hitting an unauthorized response
|
||||
// could trigger `maybeEnableOAuth` and effectively re-enable OAuth
|
||||
// on the next attempt — defeating the no-browser-launch contract.
|
||||
if (options.maxOAuthAttempts === 0 || options.disableOAuth === true) {
|
||||
return undefined;
|
||||
}
|
||||
return maybeEnableOAuth(definition, logger);
|
||||
@ -355,7 +367,8 @@ async function attemptHttpClientContext(
|
||||
throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`);
|
||||
}
|
||||
let oauthSession: OAuthSession | undefined;
|
||||
const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0;
|
||||
const shouldEstablishOAuth =
|
||||
activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0 && options.disableOAuth !== true;
|
||||
if (shouldEstablishOAuth) {
|
||||
oauthSession = await createOAuthSession(activeDefinition, logger, options.oauthSessionOptions);
|
||||
}
|
||||
|
||||
@ -17,7 +17,16 @@ type ToolSchemaInfo = {
|
||||
propertySet: Set<string>;
|
||||
};
|
||||
|
||||
const KNOWN_OPTION_KEYS = new Set(['tailLog', 'timeout', 'stream', 'streamLog', 'mimeType', 'metadata', 'log']);
|
||||
const KNOWN_OPTION_KEYS = new Set([
|
||||
'disableOAuth',
|
||||
'tailLog',
|
||||
'timeout',
|
||||
'stream',
|
||||
'streamLog',
|
||||
'mimeType',
|
||||
'metadata',
|
||||
'log',
|
||||
]);
|
||||
|
||||
export interface ServerProxyOptions {
|
||||
readonly mapPropertyToTool?: (property: string | symbol) => string;
|
||||
@ -43,6 +52,51 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isProxyOptionKey(key: string): boolean {
|
||||
return key === 'args' || KNOWN_OPTION_KEYS.has(key);
|
||||
}
|
||||
|
||||
function inferMetadataOptions(callArgs: unknown[]): {
|
||||
options: { autoAuthorize?: false; disableOAuth?: boolean };
|
||||
optionObjects: Set<Record<string, unknown>>;
|
||||
} {
|
||||
const options: { autoAuthorize?: false; disableOAuth?: boolean } = {};
|
||||
const optionObjects = new Set<Record<string, unknown>>();
|
||||
|
||||
for (const [index, arg] of callArgs.entries()) {
|
||||
if (!isPlainObject(arg) || arg.disableOAuth !== true) {
|
||||
continue;
|
||||
}
|
||||
const keys = Object.keys(arg);
|
||||
const isOptionsOnlyObject = keys.length > 0 && keys.every(isProxyOptionKey);
|
||||
const hasClearlySeparateToolArgs = callArgs.some((other, otherIndex) => {
|
||||
if (otherIndex === index) {
|
||||
return false;
|
||||
}
|
||||
if (!isPlainObject(other)) {
|
||||
return false;
|
||||
}
|
||||
return Object.hasOwn(other, 'args') || Object.keys(other).some((key) => !isProxyOptionKey(key));
|
||||
});
|
||||
// `args` plus proxy options is reserved envelope syntax; use proxy.call()
|
||||
// when a tool schema itself owns both `args` and `disableOAuth`.
|
||||
const hasExplicitArgsEnvelope = Object.hasOwn(arg, 'args');
|
||||
// A sole object can be a tool argument whose schema owns `disableOAuth`.
|
||||
// Multi-argument calls suppress discovery defensively, then let the schema
|
||||
// classify option-only objects unless another argument is clearly tool input.
|
||||
const isUnambiguousOptionsObject = isOptionsOnlyObject && (hasClearlySeparateToolArgs || hasExplicitArgsEnvelope);
|
||||
if (isUnambiguousOptionsObject) {
|
||||
options.disableOAuth = true;
|
||||
} else if (isOptionsOnlyObject && callArgs.length > 1 && options.disableOAuth !== true) {
|
||||
options.autoAuthorize = false;
|
||||
}
|
||||
if (isUnambiguousOptionsObject) {
|
||||
optionObjects.add(arg);
|
||||
}
|
||||
}
|
||||
return { options, optionObjects };
|
||||
}
|
||||
|
||||
// createToolSchemaInfo normalizes schema metadata used for argument mapping.
|
||||
function createToolSchemaInfo(schemaRaw: unknown): ToolSchemaInfo | undefined {
|
||||
if (!schemaRaw || typeof schemaRaw !== 'object') {
|
||||
@ -145,7 +199,7 @@ export function createServerProxy(
|
||||
const toolSchemaCache = new Map<string, ToolSchemaInfo>();
|
||||
const persistedSchemas = new Map<string, Record<string, unknown>>();
|
||||
const toolAliasMap = new Map<string, string>();
|
||||
let schemaFetch: Promise<void> | null = null;
|
||||
const schemaFetches = new Map<string, Promise<void>>();
|
||||
let diskLoad: Promise<void> | null = null;
|
||||
let persistPromise: Promise<void> | null = null;
|
||||
let refreshPending = false;
|
||||
@ -184,7 +238,13 @@ export function createServerProxy(
|
||||
}
|
||||
|
||||
// ensureMetadata loads schema information for the requested tool, optionally refreshing from the server.
|
||||
async function ensureMetadata(toolName: string): Promise<ToolSchemaInfo | undefined> {
|
||||
// Unambiguous proxy options use cache-friendly OAuth suppression. Ambiguous
|
||||
// option-shaped arguments use an uncached no-authorize fetch so discovery
|
||||
// cannot launch OAuth or change the runtime's active connection posture.
|
||||
async function ensureMetadata(
|
||||
toolName: string,
|
||||
metadataOptions: { autoAuthorize?: false; disableOAuth?: boolean } = {}
|
||||
): Promise<ToolSchemaInfo | undefined> {
|
||||
await consumePersist();
|
||||
const cached = toolSchemaCache.get(toolName);
|
||||
if (cached && !refreshPending) {
|
||||
@ -202,9 +262,28 @@ export function createServerProxy(
|
||||
}
|
||||
}
|
||||
|
||||
const disableOAuth = metadataOptions.disableOAuth === true;
|
||||
const schemaFetchKey = disableOAuth
|
||||
? 'disable-oauth'
|
||||
: metadataOptions.autoAuthorize === false
|
||||
? 'no-authorize'
|
||||
: 'default';
|
||||
let schemaFetch = schemaFetches.get(schemaFetchKey);
|
||||
if (!schemaFetch) {
|
||||
const listToolsOptions: {
|
||||
includeSchema: true;
|
||||
autoAuthorize?: false;
|
||||
disableOAuth?: boolean;
|
||||
} = {
|
||||
includeSchema: true,
|
||||
};
|
||||
if (disableOAuth) {
|
||||
listToolsOptions.disableOAuth = true;
|
||||
} else if (metadataOptions.autoAuthorize === false) {
|
||||
listToolsOptions.autoAuthorize = false;
|
||||
}
|
||||
schemaFetch = runtime
|
||||
.listTools(serverName, { includeSchema: true })
|
||||
.listTools(serverName, listToolsOptions)
|
||||
.then((tools) => {
|
||||
for (const tool of tools) {
|
||||
if (!tool.inputSchema || typeof tool.inputSchema !== 'object') {
|
||||
@ -216,9 +295,12 @@ export function createServerProxy(
|
||||
refreshPending = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
schemaFetch = null;
|
||||
if (schemaFetches.get(schemaFetchKey) === schemaFetch) {
|
||||
schemaFetches.delete(schemaFetchKey);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
schemaFetches.set(schemaFetchKey, schemaFetch);
|
||||
}
|
||||
|
||||
await schemaFetch;
|
||||
@ -301,9 +383,11 @@ export function createServerProxy(
|
||||
: mapPropertyToTool(propertyKey);
|
||||
|
||||
return async (...callArgs: unknown[]) => {
|
||||
const { options: metadataOptions, optionObjects } = inferMetadataOptions(callArgs);
|
||||
|
||||
let schemaInfo: ToolSchemaInfo | undefined;
|
||||
try {
|
||||
schemaInfo = await ensureMetadata(resolvedToolName);
|
||||
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
|
||||
} catch {
|
||||
schemaInfo = undefined;
|
||||
}
|
||||
@ -312,7 +396,7 @@ export function createServerProxy(
|
||||
if (alias && alias !== resolvedToolName) {
|
||||
resolvedToolName = alias;
|
||||
try {
|
||||
schemaInfo = await ensureMetadata(resolvedToolName);
|
||||
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
|
||||
} catch {
|
||||
// ignore and keep prior schema if available
|
||||
}
|
||||
@ -327,6 +411,7 @@ export function createServerProxy(
|
||||
if (isPlainObject(arg)) {
|
||||
const keys = Object.keys(arg);
|
||||
const treatAsArgs =
|
||||
!optionObjects.has(arg) &&
|
||||
schemaInfo !== undefined &&
|
||||
keys.length > 0 &&
|
||||
(keys.every((key) => schemaInfo.propertySet.has(key)) ||
|
||||
|
||||
@ -175,6 +175,12 @@ describe('parseCallArguments', () => {
|
||||
expect(parsed.positionalArgs).toEqual(['123']);
|
||||
});
|
||||
|
||||
it('captures --no-oauth as a runtime flag instead of a tool argument', () => {
|
||||
const parsed = parseCallArguments(['server.tool', '--no-oauth', 'limit=5']);
|
||||
expect(parsed.disableOAuth).toBe(true);
|
||||
expect(parsed.args).toEqual({ limit: 5 });
|
||||
});
|
||||
|
||||
it('captures --save-images output directory', () => {
|
||||
const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
|
||||
expect(parsed.saveImagesDir).toBe('./tmp/images');
|
||||
|
||||
@ -86,6 +86,7 @@ describe('CLI call execution behavior', () => {
|
||||
autoAuthorize: true,
|
||||
includeSchema: true,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@ -125,6 +126,7 @@ describe('CLI call execution behavior', () => {
|
||||
autoAuthorize: true,
|
||||
includeSchema: true,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@ -338,6 +340,7 @@ describe('CLI call execution behavior', () => {
|
||||
autoAuthorize: true,
|
||||
includeSchema: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
logSpy.mockRestore();
|
||||
|
||||
@ -260,6 +260,7 @@ describe('CLI list classification and routing', () => {
|
||||
expect(listTools).toHaveBeenCalledWith('linear', {
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ describe('CLI list flag parsing', () => {
|
||||
quiet: false,
|
||||
exitCode: false,
|
||||
statusOnly: false,
|
||||
disableOAuth: false,
|
||||
});
|
||||
expect(args).toEqual(['server']);
|
||||
});
|
||||
@ -39,10 +40,19 @@ describe('CLI list flag parsing', () => {
|
||||
quiet: false,
|
||||
exitCode: false,
|
||||
statusOnly: false,
|
||||
disableOAuth: false,
|
||||
});
|
||||
expect(args).toEqual(['server']);
|
||||
});
|
||||
|
||||
it('parses --no-oauth and removes it from args', async () => {
|
||||
const { extractListFlags } = await cliModulePromise;
|
||||
const args = ['--no-oauth', 'server'];
|
||||
const flags = extractListFlags(args);
|
||||
expect(flags.disableOAuth).toBe(true);
|
||||
expect(args).toEqual(['server']);
|
||||
});
|
||||
|
||||
it('parses --json flag and removes it from args', async () => {
|
||||
const { extractListFlags } = await cliModulePromise;
|
||||
const args = ['--json', 'server'];
|
||||
|
||||
@ -53,6 +53,17 @@ describe('handleResource', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('passes disableOAuth to resource helpers when requested', async () => {
|
||||
const runtime = createRuntime();
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await handleResource(runtime, ['docs', 'memo://one', '--no-oauth']);
|
||||
expect(runtime.readResource).toHaveBeenCalledWith('docs', 'memo://one', { disableOAuth: true });
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints structured JSON for resource listing failures', async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.listResources.mockRejectedValue(new Error('MCP error -32601: Method not found'));
|
||||
|
||||
@ -49,6 +49,7 @@ describe('daemon host request handling', () => {
|
||||
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
|
||||
args: {},
|
||||
timeoutMs: undefined,
|
||||
disableOAuth: false,
|
||||
});
|
||||
|
||||
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
||||
@ -61,6 +62,7 @@ describe('daemon host request handling', () => {
|
||||
includeSchema: true,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
@ -78,6 +80,7 @@ describe('daemon host request handling', () => {
|
||||
includeSchema: true,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
@ -95,6 +98,37 @@ describe('daemon host request handling', () => {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards disableOAuth on daemon callTool and listTools requests', async () => {
|
||||
const runtime = createRuntimeDouble();
|
||||
const managedServers = createManagedServers();
|
||||
|
||||
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
||||
id: 'call',
|
||||
method: 'callTool',
|
||||
params: { server: 'oauth', tool: 'ping', disableOAuth: true },
|
||||
});
|
||||
|
||||
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
|
||||
args: {},
|
||||
timeoutMs: undefined,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
||||
id: 'list',
|
||||
method: 'listTools',
|
||||
params: { server: 'oauth', includeSchema: true, disableOAuth: true },
|
||||
});
|
||||
|
||||
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
|
||||
includeSchema: true,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
@ -112,6 +146,7 @@ describe('daemon host request handling', () => {
|
||||
includeSchema: undefined,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: false,
|
||||
disableOAuth: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ServerDefinition } from '../src/config.js';
|
||||
import { createKeepAliveRuntime } from '../src/daemon/runtime-wrapper.js';
|
||||
import type { CallOptions, ListToolsOptions, Runtime } from '../src/runtime.js';
|
||||
import type { CallOptions, ConnectOptions, ListToolsOptions, Runtime } from '../src/runtime.js';
|
||||
|
||||
class FakeRuntime implements Runtime {
|
||||
private readonly definitions: ServerDefinition[];
|
||||
@ -10,6 +10,7 @@ class FakeRuntime implements Runtime {
|
||||
public readonly listToolsMock = vi.fn().mockResolvedValue([{ name: 'local-tool' }]);
|
||||
public readonly listResourcesMock = vi.fn().mockResolvedValue([]);
|
||||
public readonly readResourceMock = vi.fn().mockResolvedValue({ contents: [] });
|
||||
public readonly connectMock = vi.fn().mockResolvedValue({ client: {}, transport: {}, definition: {} });
|
||||
public readonly closeMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor(definitions: ServerDefinition[]) {
|
||||
@ -56,8 +57,8 @@ class FakeRuntime implements Runtime {
|
||||
return await this.readResourceMock(server, uri);
|
||||
}
|
||||
|
||||
async connect(): Promise<never> {
|
||||
throw new Error('not implemented');
|
||||
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||
return await this.connectMock(server, options);
|
||||
}
|
||||
|
||||
async close(server?: string): Promise<void> {
|
||||
@ -102,6 +103,7 @@ describe('createKeepAliveRuntime', () => {
|
||||
tool: 'ping',
|
||||
args: { value: 1 },
|
||||
timeoutMs: 4_200,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.listTools('alpha', { includeSchema: true });
|
||||
@ -110,6 +112,7 @@ describe('createKeepAliveRuntime', () => {
|
||||
includeSchema: true,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.listTools('alpha', { allowCachedAuth: false });
|
||||
@ -118,15 +121,26 @@ describe('createKeepAliveRuntime', () => {
|
||||
includeSchema: undefined,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: false,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.listResources('alpha', { cursor: '1' });
|
||||
expect(daemon.listResources).toHaveBeenCalledWith({ server: 'alpha', params: { cursor: '1' } });
|
||||
expect(daemon.listResources).toHaveBeenCalledWith({
|
||||
server: 'alpha',
|
||||
params: { cursor: '1' },
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
await expect(keepAliveRuntime.readResource('alpha', 'memo://1')).resolves.toEqual({
|
||||
contents: [{ uri: 'memo://1', text: 'daemon-resource' }],
|
||||
});
|
||||
expect(daemon.readResource).toHaveBeenCalledWith({ server: 'alpha', uri: 'memo://1' });
|
||||
expect(daemon.readResource).toHaveBeenCalledWith({
|
||||
server: 'alpha',
|
||||
uri: 'memo://1',
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: undefined,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.close('alpha');
|
||||
expect(daemon.closeServer).toHaveBeenCalledWith({ server: 'alpha' });
|
||||
@ -138,6 +152,58 @@ describe('createKeepAliveRuntime', () => {
|
||||
expect(runtime.closeMock).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('forwards disableOAuth through daemon requests and connect wrappers', async () => {
|
||||
const runtime = new FakeRuntime(definitions);
|
||||
const daemon = {
|
||||
callTool: vi.fn().mockResolvedValue('daemon-call'),
|
||||
listTools: vi.fn().mockResolvedValue([{ name: 'remote-tool' }]),
|
||||
listResources: vi.fn().mockResolvedValue(['resource']),
|
||||
readResource: vi.fn().mockResolvedValue({ contents: [] }),
|
||||
closeServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const keepAliveRuntime = createKeepAliveRuntime(runtime as unknown as Runtime, {
|
||||
daemonClient: daemon as never,
|
||||
keepAliveServers: new Set(['alpha']),
|
||||
});
|
||||
|
||||
await keepAliveRuntime.callTool('alpha', 'ping', { disableOAuth: true });
|
||||
expect(daemon.callTool).toHaveBeenCalledWith({
|
||||
server: 'alpha',
|
||||
tool: 'ping',
|
||||
args: undefined,
|
||||
timeoutMs: undefined,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.listTools('alpha', { disableOAuth: true });
|
||||
expect(daemon.listTools).toHaveBeenCalledWith({
|
||||
server: 'alpha',
|
||||
includeSchema: undefined,
|
||||
autoAuthorize: undefined,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.listResources('alpha', { cursor: '1', disableOAuth: true });
|
||||
expect(daemon.listResources).toHaveBeenCalledWith({
|
||||
server: 'alpha',
|
||||
params: { cursor: '1' },
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.readResource('alpha', 'memo://1', { disableOAuth: true });
|
||||
expect(daemon.readResource).toHaveBeenCalledWith({
|
||||
server: 'alpha',
|
||||
uri: 'memo://1',
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
await keepAliveRuntime.connect('alpha', { disableOAuth: true });
|
||||
expect(runtime.connectMock).toHaveBeenCalledWith('alpha', { disableOAuth: true });
|
||||
});
|
||||
|
||||
it('restarts daemon servers after fatal errors and retries the operation', async () => {
|
||||
const runtime = new FakeRuntime(definitions);
|
||||
const daemon = {
|
||||
|
||||
125
tests/runtime-cache-policy.test.ts
Normal file
125
tests/runtime-cache-policy.test.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createClientContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/runtime/transport.js', () => ({
|
||||
createClientContext: mocks.createClientContext,
|
||||
}));
|
||||
|
||||
import type { ServerDefinition } from '../src/config.js';
|
||||
import { createRuntime } from '../src/runtime.js';
|
||||
|
||||
type ClientContext = Awaited<ReturnType<Awaited<ReturnType<typeof createRuntime>>['connect']>>;
|
||||
|
||||
function fakeContext(
|
||||
definition: ServerDefinition,
|
||||
clientClose: ReturnType<typeof vi.fn> = vi.fn().mockResolvedValue(undefined)
|
||||
): ClientContext {
|
||||
return {
|
||||
client: {
|
||||
close: clientClose,
|
||||
},
|
||||
transport: {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
definition,
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
}
|
||||
|
||||
describe('runtime cache policy', () => {
|
||||
beforeEach(() => {
|
||||
mocks.createClientContext.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not let stale OAuth promotion overwrite a replacement definition', async () => {
|
||||
const initial: ServerDefinition = {
|
||||
name: 'oauth',
|
||||
command: { kind: 'http', url: new URL('https://old.example.com/mcp') },
|
||||
};
|
||||
let resolveConnection!: (context: ClientContext) => void;
|
||||
let promote!: (definition: ServerDefinition) => void;
|
||||
mocks.createClientContext.mockImplementation(
|
||||
(
|
||||
_definition: ServerDefinition,
|
||||
_logger: unknown,
|
||||
_clientInfo: unknown,
|
||||
options: { onDefinitionPromoted?: (definition: ServerDefinition) => void }
|
||||
) => {
|
||||
promote = options.onDefinitionPromoted ?? (() => {});
|
||||
return new Promise<ClientContext>((resolve) => {
|
||||
resolveConnection = resolve;
|
||||
});
|
||||
}
|
||||
);
|
||||
const runtime = await createRuntime({ servers: [initial] });
|
||||
const connecting = runtime.connect('oauth');
|
||||
const expectation = expect(connecting).rejects.toThrow('superseded');
|
||||
await vi.waitFor(() => expect(mocks.createClientContext).toHaveBeenCalled());
|
||||
|
||||
const replacement: ServerDefinition = {
|
||||
name: 'oauth',
|
||||
command: { kind: 'http', url: new URL('https://new.example.com/mcp') },
|
||||
};
|
||||
runtime.registerDefinition(replacement, { overwrite: true });
|
||||
promote({ ...initial, auth: 'oauth' });
|
||||
resolveConnection(fakeContext(initial));
|
||||
|
||||
await expectation;
|
||||
expect(runtime.getDefinition('oauth')).toBe(replacement);
|
||||
});
|
||||
|
||||
it('uses one replay client across auth posture changes', async () => {
|
||||
vi.stubEnv('MCPORTER_REPLAY', 'cache-policy-test');
|
||||
const definition: ServerDefinition = {
|
||||
name: 'replay',
|
||||
command: { kind: 'http', url: new URL('https://replay.example.com/mcp') },
|
||||
};
|
||||
const context = fakeContext(definition);
|
||||
mocks.createClientContext.mockResolvedValue(context);
|
||||
const runtime = await createRuntime({ servers: [definition] });
|
||||
|
||||
const first = await runtime.connect('replay');
|
||||
const second = await runtime.connect('replay', {
|
||||
allowCachedAuth: false,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(mocks.createClientContext).toHaveBeenCalledOnce();
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
it('keeps auth posture isolation for servers excluded by the replay filter', async () => {
|
||||
vi.stubEnv('MCPORTER_REPLAY', 'cache-policy-test');
|
||||
vi.stubEnv('MCPORTER_REPLAY_SERVER', 'other-server');
|
||||
const definition: ServerDefinition = {
|
||||
name: 'live',
|
||||
command: { kind: 'http', url: new URL('https://live.example.com/mcp') },
|
||||
};
|
||||
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
|
||||
mocks.createClientContext.mockImplementation((current: ServerDefinition) => {
|
||||
const closeMock = vi.fn().mockResolvedValue(undefined);
|
||||
const context = fakeContext(current, closeMock);
|
||||
closeMocks.push(closeMock);
|
||||
return Promise.resolve(context);
|
||||
});
|
||||
const runtime = await createRuntime({ servers: [definition] });
|
||||
|
||||
const first = await runtime.connect('live');
|
||||
const second = await runtime.connect('live', {
|
||||
allowCachedAuth: false,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(mocks.createClientContext).toHaveBeenCalledTimes(2);
|
||||
expect(closeMocks[0]).toHaveBeenCalled();
|
||||
await runtime.close();
|
||||
});
|
||||
});
|
||||
157
tests/runtime-cache.test.ts
Normal file
157
tests/runtime-cache.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createRuntime } from '../src/runtime.js';
|
||||
|
||||
type TestRuntime = Awaited<ReturnType<typeof createRuntime>>;
|
||||
type ClientContext = Awaited<ReturnType<TestRuntime['connect']>>;
|
||||
type CachedClientEntry = {
|
||||
readonly server: string;
|
||||
readonly promise: Promise<ClientContext>;
|
||||
readonly allowCachedAuth: boolean | undefined;
|
||||
readonly disableOAuth: boolean;
|
||||
};
|
||||
|
||||
function fakeContext(instructions: string): ClientContext {
|
||||
return {
|
||||
client: {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getInstructions: vi.fn(() => instructions),
|
||||
},
|
||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
}
|
||||
|
||||
describe('runtime cache entries', () => {
|
||||
it('reads instructions from the active cached entry', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
const older = fakeContext('older instructions');
|
||||
const active = fakeContext('active instructions');
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<string, CachedClientEntry>;
|
||||
activeClientKeys: Map<string, string>;
|
||||
};
|
||||
|
||||
internals.clients.set('temp:older', {
|
||||
server: 'temp',
|
||||
promise: Promise.resolve(older),
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.clients.set('temp:active', {
|
||||
server: 'temp',
|
||||
promise: Promise.resolve(active),
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
internals.activeClientKeys.set('temp', 'temp:active');
|
||||
|
||||
await expect(runtime.getInstructions?.('temp')).resolves.toBe('active instructions');
|
||||
});
|
||||
|
||||
it('closes cached entries when replacing a server definition', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
const context = fakeContext('old instructions');
|
||||
const transport = context.transport as unknown as { close: ReturnType<typeof vi.fn> };
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<string, CachedClientEntry>;
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
};
|
||||
|
||||
internals.clients.set('temp:old', {
|
||||
server: 'temp',
|
||||
promise: Promise.resolve(context),
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.contextCacheKeys.set(context, 'temp:old');
|
||||
|
||||
runtime.registerDefinition(
|
||||
{
|
||||
name: 'temp',
|
||||
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
{ overwrite: true }
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(transport.close).toHaveBeenCalled());
|
||||
expect(internals.clients.has('temp:old')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes cached entries before awaiting shutdown', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
let releaseClose!: () => void;
|
||||
const clientClose = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
})
|
||||
);
|
||||
const context = {
|
||||
...fakeContext('closing instructions'),
|
||||
client: {
|
||||
close: clientClose,
|
||||
getInstructions: vi.fn(() => 'closing instructions'),
|
||||
},
|
||||
} as unknown as ClientContext;
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<string, CachedClientEntry>;
|
||||
activeClientKeys: Map<string, string>;
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
};
|
||||
internals.clients.set('temp:closing', {
|
||||
server: 'temp',
|
||||
promise: Promise.resolve(context),
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.activeClientKeys.set('temp', 'temp:closing');
|
||||
internals.contextCacheKeys.set(context, 'temp:closing');
|
||||
|
||||
const closing = runtime.close('temp');
|
||||
|
||||
expect(internals.clients.has('temp:closing')).toBe(false);
|
||||
expect(internals.activeClientKeys.has('temp')).toBe(false);
|
||||
await vi.waitFor(() => expect(clientClose).toHaveBeenCalled());
|
||||
releaseClose();
|
||||
await closing;
|
||||
});
|
||||
|
||||
it('starts closing cached variants concurrently', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
let resolvePending!: (context: ClientContext) => void;
|
||||
const pending = new Promise<ClientContext>((resolve) => {
|
||||
resolvePending = resolve;
|
||||
});
|
||||
const pendingContext = fakeContext('pending instructions');
|
||||
const readyContext = fakeContext('ready instructions');
|
||||
const readyTransport = readyContext.transport as unknown as { close: ReturnType<typeof vi.fn> };
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<string, CachedClientEntry>;
|
||||
};
|
||||
internals.clients.set('temp:pending', {
|
||||
server: 'temp',
|
||||
promise: pending,
|
||||
allowCachedAuth: false,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.clients.set('temp:ready', {
|
||||
server: 'temp',
|
||||
promise: Promise.resolve(readyContext),
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
const closing = runtime.close('temp');
|
||||
|
||||
await vi.waitFor(() => expect(readyTransport.close).toHaveBeenCalled());
|
||||
resolvePending(pendingContext);
|
||||
await closing;
|
||||
});
|
||||
});
|
||||
@ -48,9 +48,10 @@ describe('runtime callTool timeouts', () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
const callTool = vi.fn(() => new Promise(() => {}));
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
const fakeContext = {
|
||||
client: { callTool },
|
||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
transport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
@ -60,16 +61,42 @@ describe('runtime callTool timeouts', () => {
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
vi.spyOn(runtime, 'connect').mockResolvedValue(fakeContext);
|
||||
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
|
||||
'temp',
|
||||
Promise.resolve(fakeContext)
|
||||
);
|
||||
const cachedPromise = Promise.resolve(fakeContext);
|
||||
(
|
||||
runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
).clients.set('temp:test', {
|
||||
server: 'temp',
|
||||
promise: cachedPromise,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
(
|
||||
runtime as unknown as {
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
}
|
||||
).contextCacheKeys.set(fakeContext, 'temp:test');
|
||||
(
|
||||
runtime as unknown as {
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
}
|
||||
).contextCachePromises.set(fakeContext, cachedPromise);
|
||||
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
||||
|
||||
const promise = runtime.callTool('temp', 'ping', { timeoutMs: 123 });
|
||||
const expectation = expect(promise).rejects.toThrow('Timeout');
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await expectation;
|
||||
expect(closeSpy).toHaveBeenCalledWith('temp');
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
expect(transport.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function throwConnectBoom(): never {
|
||||
throw new Error('connect boom');
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const connectMock = vi.fn();
|
||||
const listToolsMock = vi.fn();
|
||||
@ -108,7 +115,7 @@ vi.mock('../src/oauth-persistence.js', () => ({
|
||||
readCachedAccessToken: mocks.readCachedAccessTokenMock,
|
||||
}));
|
||||
|
||||
import { createRuntime } from '../src/runtime.js';
|
||||
import { callOnce, createRuntime } from '../src/runtime.js';
|
||||
|
||||
describe('mcporter composability', () => {
|
||||
beforeEach(() => {
|
||||
@ -228,6 +235,30 @@ describe('mcporter composability', () => {
|
||||
expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-parent');
|
||||
});
|
||||
|
||||
it('reuses stdio clients across auth-policy no-op differences', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'local',
|
||||
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.connect('local');
|
||||
await runtime.callTool('local', 'echo', {});
|
||||
await runtime.connect('local', { disableOAuth: true });
|
||||
await runtime.listTools('local', { autoAuthorize: false });
|
||||
|
||||
expect(mocks.stdioInstances).toHaveLength(1);
|
||||
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('overrides inherited env vars with server-specific values', async () => {
|
||||
vi.stubEnv('MCPORTER_STDIO_TEST', 'parent');
|
||||
const runtime = await createRuntime({
|
||||
@ -271,6 +302,375 @@ describe('mcporter composability', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a disabled-OAuth cached connection through high-level helpers', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: true });
|
||||
await runtime.callTool('oauth', 'ping');
|
||||
await runtime.listTools('oauth');
|
||||
await runtime.listResources('oauth');
|
||||
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses active cached-auth connections for resource helpers with unspecified auth policy', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
|
||||
await runtime.listTools('oauth');
|
||||
await runtime.listResources('oauth');
|
||||
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses disableOAuth on cold callTool/listTools helper connections', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
auth: 'oauth' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.callTool('oauth', 'ping', { disableOAuth: true });
|
||||
await runtime.listTools('oauth', { disableOAuth: true });
|
||||
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves cached-auth opt out for disabled-OAuth helper calls', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
auth: 'oauth' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: false });
|
||||
await runtime.callTool('oauth', 'ping');
|
||||
await runtime.listTools('oauth');
|
||||
await runtime.listResources('oauth');
|
||||
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
expect(mocks.readCachedAccessTokenMock).not.toHaveBeenCalled();
|
||||
await runtime.connect('oauth', { disableOAuth: true });
|
||||
expect(mocks.streamableInstances).toHaveLength(2);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps separate cached transports for OAuth posture changes', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const disabled = await runtime.connect('oauth', { disableOAuth: true });
|
||||
const disabledTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
|
||||
const normal = await runtime.connect('oauth');
|
||||
|
||||
expect(normal).not.toBe(disabled);
|
||||
expect(mocks.streamableInstances).toHaveLength(2);
|
||||
expect(disabledTransport.close).not.toHaveBeenCalled();
|
||||
await expect(runtime.connect('oauth', { disableOAuth: true })).resolves.toBe(disabled);
|
||||
await runtime.callTool('oauth', 'ping');
|
||||
expect(mocks.streamableInstances).toHaveLength(2);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the previous active cached variant when a new variant fails to connect', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.connect('oauth');
|
||||
await runtime.connect('oauth', { disableOAuth: true });
|
||||
const internals = runtime as unknown as {
|
||||
activeClientKeys: Map<string, string>;
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const disabledKey = [...internals.clients.entries()].find(
|
||||
([, cached]) => cached.disableOAuth && cached.allowCachedAuth === true
|
||||
)?.[0];
|
||||
|
||||
mocks.connectMock.mockImplementationOnce(throwConnectBoom).mockImplementationOnce(throwConnectBoom);
|
||||
await expect(runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: false })).rejects.toThrow(
|
||||
'connect boom'
|
||||
);
|
||||
|
||||
expect(internals.activeClientKeys.get('oauth')).toBe(disabledKey);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes concurrent OAuth-capable HTTP variant setup', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
let releaseFirst!: () => void;
|
||||
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
|
||||
transport.start?.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const first = runtime.connect('oauth', { allowCachedAuth: false });
|
||||
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
|
||||
const second = runtime.connect('oauth', { allowCachedAuth: true });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
releaseFirst();
|
||||
await first;
|
||||
await second;
|
||||
expect(mocks.streamableInstances).toHaveLength(2);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a new OAuth-capable variant after close interrupts retirement', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
await runtime.connect('oauth', { allowCachedAuth: false });
|
||||
let releaseClose!: () => void;
|
||||
const firstClient = mocks.clientInstances[0] as { close: () => Promise<void> };
|
||||
firstClient.close = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const replacement = runtime.connect('oauth', { allowCachedAuth: true });
|
||||
const replacementExpectation = expect(replacement).rejects.toThrow('superseded');
|
||||
await vi.waitFor(() => expect(firstClient.close).toHaveBeenCalled());
|
||||
const closing = runtime.close('oauth');
|
||||
releaseClose();
|
||||
|
||||
await Promise.all([replacementExpectation, closing]);
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('releases serialized setup after conflicting-entry retirement fails', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const rejected = Promise.reject(new Error('retire boom')) as Promise<ClientContext>;
|
||||
void rejected.catch(() => {});
|
||||
(
|
||||
runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
contextPromise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
).clients.set('oauth:conflict', {
|
||||
server: 'oauth',
|
||||
promise: rejected,
|
||||
contextPromise: rejected,
|
||||
allowCachedAuth: false,
|
||||
disableOAuth: false,
|
||||
});
|
||||
|
||||
await expect(runtime.connect('oauth', { allowCachedAuth: true })).rejects.toThrow('retire boom');
|
||||
await expect(runtime.connect('oauth', { allowCachedAuth: true })).resolves.toBeDefined();
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
it('cancels queued OAuth-capable setup when the server closes', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
let releaseFirst!: () => void;
|
||||
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
|
||||
transport.start?.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const first = runtime.connect('oauth', { allowCachedAuth: false });
|
||||
const firstExpectation = expect(first).rejects.toThrow('superseded');
|
||||
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
|
||||
const second = runtime.connect('oauth', { allowCachedAuth: true });
|
||||
const secondExpectation = expect(second).rejects.toThrow('superseded');
|
||||
await Promise.resolve();
|
||||
|
||||
const closing = runtime.close('oauth');
|
||||
releaseFirst();
|
||||
await Promise.all([firstExpectation, secondExpectation, closing]);
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects an in-flight connection when its definition is replaced', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://old.example.com/mcp') },
|
||||
},
|
||||
],
|
||||
});
|
||||
let releaseConnect!: () => void;
|
||||
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
|
||||
transport.start?.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseConnect = resolve;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const connecting = runtime.connect('oauth');
|
||||
const waiting = runtime.connect('oauth');
|
||||
const expectations = Promise.all([
|
||||
expect(connecting).rejects.toThrow('superseded'),
|
||||
expect(waiting).rejects.toThrow('superseded'),
|
||||
]);
|
||||
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
|
||||
runtime.registerDefinition(
|
||||
{
|
||||
name: 'oauth',
|
||||
command: { kind: 'http' as const, url: new URL('https://new.example.com/mcp') },
|
||||
},
|
||||
{ overwrite: true }
|
||||
);
|
||||
releaseConnect();
|
||||
|
||||
await expectations;
|
||||
const oldTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
|
||||
await vi.waitFor(() => expect(oldTransport.close).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('forwards disableOAuth through callOnce', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-call-once-'));
|
||||
const configPath = path.join(tempDir, 'mcporter.json');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
oauth: {
|
||||
url: 'https://oauth.example.com/mcp',
|
||||
auth: 'oauth',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
try {
|
||||
await callOnce({
|
||||
server: 'oauth',
|
||||
toolName: 'ping',
|
||||
args: { ok: true },
|
||||
configPath,
|
||||
disableOAuth: true,
|
||||
});
|
||||
|
||||
expect(mocks.callToolMock).toHaveBeenCalledWith({
|
||||
name: 'ping',
|
||||
arguments: { ok: true },
|
||||
});
|
||||
const streamableTransport = mocks.streamableInstances[0] as {
|
||||
options?: { authProvider?: unknown };
|
||||
};
|
||||
expect(streamableTransport.options?.authProvider).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reconnects when callTool needs cached auth after an uncached connection', async () => {
|
||||
const runtime = await createRuntime({
|
||||
servers: [
|
||||
@ -284,11 +684,13 @@ describe('mcporter composability', () => {
|
||||
try {
|
||||
await runtime.listTools('oauth', { allowCachedAuth: false });
|
||||
expect(mocks.streamableInstances).toHaveLength(1);
|
||||
const firstTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
|
||||
|
||||
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
|
||||
await runtime.callTool('oauth', 'ping');
|
||||
|
||||
expect(mocks.streamableInstances).toHaveLength(2);
|
||||
expect(firstTransport.close).toHaveBeenCalled();
|
||||
const streamableTransport = mocks.streamableInstances[1] as {
|
||||
options?: { requestInit?: { headers?: Record<string, string> } };
|
||||
};
|
||||
|
||||
@ -11,11 +11,12 @@ describe('runtime connection resets', () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
const context = {
|
||||
client: {
|
||||
callTool: vi.fn().mockRejectedValue(rejected),
|
||||
},
|
||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
transport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
@ -25,25 +26,53 @@ describe('runtime connection resets', () => {
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
|
||||
'temp',
|
||||
Promise.resolve(context)
|
||||
);
|
||||
const promise = Promise.resolve(context);
|
||||
(
|
||||
runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
).clients.set('temp:test', {
|
||||
server: 'temp',
|
||||
promise,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
(
|
||||
runtime as unknown as {
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
}
|
||||
).contextCacheKeys.set(context, 'temp:test');
|
||||
(
|
||||
runtime as unknown as {
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
}
|
||||
).contextCachePromises.set(context, promise);
|
||||
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
||||
|
||||
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
|
||||
expect(closeSpy).toHaveBeenCalledWith('temp');
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
expect(transport.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the connection open for user-facing InvalidParams errors', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const rejected = new McpError(ErrorCode.InvalidParams, 'Tool help not found');
|
||||
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
const context = {
|
||||
client: {
|
||||
callTool: vi.fn().mockRejectedValue(rejected),
|
||||
},
|
||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
transport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
@ -53,13 +82,222 @@ describe('runtime connection resets', () => {
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
|
||||
'temp',
|
||||
Promise.resolve(context)
|
||||
);
|
||||
const promise = Promise.resolve(context);
|
||||
(
|
||||
runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
).clients.set('temp:test', {
|
||||
server: 'temp',
|
||||
promise,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
(
|
||||
runtime as unknown as {
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
}
|
||||
).contextCacheKeys.set(context, 'temp:test');
|
||||
(
|
||||
runtime as unknown as {
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
}
|
||||
).contextCachePromises.set(context, promise);
|
||||
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
||||
|
||||
await expect(runtime.callTool('temp', 'help')).rejects.toThrow('Tool help not found');
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
expect(transport.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not wait for unrelated cached connections when resetting a failed context', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
const context = {
|
||||
client: {
|
||||
callTool: vi.fn().mockRejectedValue(rejected),
|
||||
},
|
||||
transport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
const unresolved = new Promise<ClientContext>(() => {});
|
||||
const failedPromise = Promise.resolve(context);
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
};
|
||||
internals.clients.set('temp:unrelated', {
|
||||
server: 'temp',
|
||||
promise: unresolved,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.clients.set('temp:failed', {
|
||||
server: 'temp',
|
||||
promise: failedPromise,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
internals.contextCacheKeys.set(context, 'temp:failed');
|
||||
internals.contextCachePromises.set(context, failedPromise);
|
||||
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||
|
||||
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
|
||||
expect(transport.close).toHaveBeenCalled();
|
||||
expect(internals.clients.has('temp:failed')).toBe(false);
|
||||
expect(internals.clients.has('temp:unrelated')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves cached entries alone when an uncached list operation fails', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||
const cachedTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
const uncachedTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
const cachedContext = {
|
||||
client: {},
|
||||
transport: cachedTransport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
const uncachedContext = {
|
||||
client: {
|
||||
listTools: vi.fn().mockRejectedValue(rejected),
|
||||
},
|
||||
transport: uncachedTransport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
};
|
||||
internals.clients.set('temp:cached', {
|
||||
server: 'temp',
|
||||
promise: Promise.resolve(cachedContext),
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.contextCacheKeys.set(cachedContext, 'temp:cached');
|
||||
vi.spyOn(runtime, 'connect').mockResolvedValue(uncachedContext);
|
||||
|
||||
await expect(runtime.listTools('temp', { autoAuthorize: false })).rejects.toThrow('Connection closed');
|
||||
expect(uncachedTransport.close).toHaveBeenCalled();
|
||||
expect(cachedTransport.close).not.toHaveBeenCalled();
|
||||
expect(internals.clients.has('temp:cached')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not evict a replacement while closing a failed stdio context', async () => {
|
||||
const runtime = await createRuntime({ servers: [] });
|
||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||
let releaseClose!: () => void;
|
||||
const transport = {
|
||||
close: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
})
|
||||
),
|
||||
};
|
||||
const context = {
|
||||
client: {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
callTool: vi.fn().mockRejectedValue(rejected),
|
||||
},
|
||||
transport,
|
||||
definition: {
|
||||
name: 'temp',
|
||||
description: 'test',
|
||||
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||
source: { kind: 'local', path: '<test>' },
|
||||
},
|
||||
oauthSession: undefined,
|
||||
} as unknown as ClientContext;
|
||||
const promise = Promise.resolve(context);
|
||||
const internals = runtime as unknown as {
|
||||
clients: Map<
|
||||
string,
|
||||
{
|
||||
server: string;
|
||||
promise: Promise<ClientContext>;
|
||||
allowCachedAuth: boolean | undefined;
|
||||
disableOAuth: boolean;
|
||||
}
|
||||
>;
|
||||
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||
};
|
||||
internals.clients.set('temp:stdio', {
|
||||
server: 'temp',
|
||||
promise,
|
||||
allowCachedAuth: undefined,
|
||||
disableOAuth: false,
|
||||
});
|
||||
internals.contextCacheKeys.set(context, 'temp:stdio');
|
||||
internals.contextCachePromises.set(context, promise);
|
||||
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||
|
||||
const call = runtime.callTool('temp', 'list_pages');
|
||||
const expectation = expect(call).rejects.toThrow('Connection closed');
|
||||
await vi.waitFor(() => expect(transport.close).toHaveBeenCalled());
|
||||
const replacement = Promise.resolve({
|
||||
...context,
|
||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
} as unknown as ClientContext);
|
||||
internals.clients.set('temp:stdio', {
|
||||
server: 'temp',
|
||||
promise: replacement,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
releaseClose();
|
||||
|
||||
await expectation;
|
||||
expect(internals.clients.get('temp:stdio')?.promise).toBe(replacement);
|
||||
});
|
||||
});
|
||||
|
||||
@ -133,4 +133,157 @@ describe('runtime integration', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -352,6 +352,80 @@ describe('createClientContext (HTTP)', () => {
|
||||
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');
|
||||
|
||||
|
||||
@ -333,4 +333,273 @@ describe('createServerProxy', () => {
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,6 +40,14 @@ describe('loadToolMetadata', () => {
|
||||
expect(listTools).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('differentiates cache entries by disableOAuth flag', async () => {
|
||||
const listTools = vi.fn(async () => [demoTool]);
|
||||
const runtime = createRuntimeStub(listTools);
|
||||
await loadToolMetadata(runtime, 'integration', { includeSchema: true });
|
||||
await loadToolMetadata(runtime, 'integration', { includeSchema: true, disableOAuth: true });
|
||||
expect(listTools).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes cached OAuth preference to the runtime', async () => {
|
||||
const listTools = vi.fn(async () => [demoTool]);
|
||||
const runtime = createRuntimeStub(listTools);
|
||||
@ -47,11 +55,13 @@ describe('loadToolMetadata', () => {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
expect(listTools).toHaveBeenCalledWith('integration', {
|
||||
includeSchema: true,
|
||||
autoAuthorize: false,
|
||||
allowCachedAuth: true,
|
||||
disableOAuth: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user