Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`

Expand All @@ -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]`

Expand All @@ -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>]`

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
170 changes: 170 additions & 0 deletions examples/headless-pooling-demo.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -583,16 +584,36 @@ 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({
server,
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.`);
},
Expand Down
7 changes: 7 additions & 0 deletions src/cli/call-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface CallArgsParseResult {
tailLog: boolean;
output: OutputFormat;
timeoutMs?: number;
disableOAuth?: boolean;
ephemeral?: EphemeralServerSpec;
rawStrings?: boolean;
saveImagesDir?: string;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading