Auto-detect OAuth for ad-hoc servers

This commit is contained in:
Peter Steinberger 2025-11-07 00:39:09 +00:00
parent 6eeb9ccec1
commit 7a17aaa44b
6 changed files with 162 additions and 49 deletions

View File

@ -13,6 +13,7 @@
- Flag-style tool invocations now accept `key:value` and `key: value` alongside the existing `key=value` form, making commands like `mcporter context7.resolve-library-id libraryName:value` Just Work. Documented in the README/call syntax guide and covered by `tests/cli-call.test.ts`.
- Added `docs/tool-calling.md`, a cheatsheet summarizing every supported invocation pattern (inferred verbs, flag styles, function-call syntax, and ad-hoc URL workflows).
- Function-call syntax now allows unlabeled arguments; mcporter maps them to schema order after any explicitly named parameters (e.g. `mcporter 'context7.resolve-library-id("react")'`). Tests in `tests/cli-call.test.ts` cover the positional fallback.
- Ad-hoc HTTP servers that respond with 401/403 are automatically promoted to OAuth mode (no manual config edits needed) and trigger the browser sign-in flow on the next attempt. The helper is covered in `tests/runtime-oauth-detection.test.ts`, and the workflow is documented in `README.md` / `docs/adhoc.md` / `docs/spec.md`.
## [0.3.0] - 2025-11-06

View File

@ -9,7 +9,7 @@ mcporter helps you lean into the "code execution" workflows highlighted in Anthr
- **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration.
- **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, and `.content()` helpers.
- **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface.
- **Ad-hoc connections.** Point the CLI at *any* MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. See [docs/adhoc.md](docs/adhoc.md).
- **Ad-hoc connections.** Point the CLI at *any* MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. Hosted MCPs that expect a browser login (Supabase, Vercel, etc.) are auto-detected—just run `mcporter auth <url>` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md).
## Quick Start

View File

@ -1,6 +1,6 @@
# Ad-hoc MCP Servers
`mcporter` is gaining support for "just try it" workflows where you point the CLI at a raw MCP endpoint without first editing a config file. This doc tracks the behavior and heuristics we use to make that experience smooth while keeping the runtime predictable.
mcporter is gaining support for "just try it" workflows where you point the CLI at a raw MCP endpoint without first editing a config file. This doc tracks the behavior and heuristics we use to make that experience smooth while keeping the runtime predictable.
## Entry Points
@ -22,11 +22,20 @@ You can also pass a bare URL as the selector (`mcporter list https://mcp.linear.
- `--name` wins when provided.
- Otherwise we derive a slug:
- HTTP: `<host>` plus a sanitized path fragment (e.g. `mcp-linear-app-mcp`).
- STDIO: executable basename + script (`node-mcp-server`).
- STDIO: executable basename + script (`node-singlestep`).
- The inferred name is printed so you know what to reuse later. If you dont persist the definition, run `mcporter auth https://mcp.linear.app/mcp` (or supply `--name linear` so `mcporter auth linear` also works) to finish OAuth with the same settings.
This name becomes the cache key for OAuth tokens and log preferences, so repeated ad-hoc calls still benefit from credential reuse.
## OAuth Auto-Detection
Many hosted MCP servers (Supabase, Vercel, etc.) advertise OAuth capabilities but expect clients to discover this dynamically. When an ad-hoc HTTP server responds with `401/403` during the initial handshake, mcporter now:
1. **Promotes the definition to OAuth** and spins up the default browser flow—no need to edit config or supply `auth: "oauth"` manually.
2. **Persists the change** whenever you pass `--persist`, so future runs remember that the endpoint requires OAuth without repeating the detection step.
The CLI still avoids surprise prompts during `mcporter list`; the upgrade happens the first time you run `mcporter auth <url>` or any other command that allows OAuth (i.e., not in `--autoAuthorize=false` mode).
## Auth & Persistence
- OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions.

View File

@ -24,6 +24,7 @@ summary: 'Plan for the mcporter package replacing the Sweetistics pnpm MCP helpe
## Architecture Notes
- Load MCP definitions from JSON (support relative paths + HTTPS).
- Reuse `@modelcontextprotocol/sdk` transports; invoke stdio servers directly (e.g., call `npx` with env overrides) without an extra wrapper script.
- Automatically detect OAuth requirements for ad-hoc HTTP servers by retrying failed handshakes and promoting the definition to `auth: "oauth"` when a 401/403 is encountered, then launching the browser flow immediately.
- Mirror Python helper behavior:
- `${VAR}`, `${VAR:-default}`, `$env:VAR` interpolation.
- Optional OAuth token cache directory handling (defaulting to `~/.mcporter/<server>` when none is provided).

View File

@ -1,6 +1,7 @@
import type { ChildProcess } from 'node:child_process';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@ -250,73 +251,114 @@ class McpRuntime implements Runtime {
private async createClient(definition: ServerDefinition, options: ConnectOptions = {}): Promise<ClientContext> {
// Create a fresh MCP client context for the target server.
const client = new Client(this.clientInfo);
let activeDefinition = definition;
return withEnvOverrides(definition.env, async () => {
let oauthSession: OAuthSession | undefined;
const shouldEstablishOAuth = definition.auth === 'oauth' && options.maxOAuthAttempts !== 0;
if (shouldEstablishOAuth) {
oauthSession = await createOAuthSession(definition, this.logger);
}
if (definition.command.kind === 'stdio') {
return withEnvOverrides(activeDefinition.env, async () => {
if (activeDefinition.command.kind === 'stdio') {
const resolvedEnv =
definition.env && Object.keys(definition.env).length > 0
activeDefinition.env && Object.keys(activeDefinition.env).length > 0
? Object.fromEntries(
Object.entries(definition.env)
Object.entries(activeDefinition.env)
.map(([key, raw]) => [key, resolveEnvValue(raw)])
.filter(([, value]) => value !== '')
)
: undefined;
const transport = new StdioClientTransport({
command: definition.command.command,
args: definition.command.args,
cwd: definition.command.cwd,
command: activeDefinition.command.command,
args: activeDefinition.command.args,
cwd: activeDefinition.command.cwd,
env: resolvedEnv,
});
await client.connect(transport);
return { client, transport, definition, oauthSession };
return { client, transport, definition: activeDefinition, oauthSession: undefined };
}
const resolvedHeaders = materializeHeaders(definition.command.headers, definition.name);
// HTTP transports may need to retry once OAuth is auto-enabled.
while (true) {
const command = activeDefinition.command;
if (command.kind !== 'http') {
throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`);
}
let oauthSession: OAuthSession | undefined;
const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0;
if (shouldEstablishOAuth) {
oauthSession = await createOAuthSession(activeDefinition, this.logger);
}
const requestInit: RequestInit | undefined = resolvedHeaders
? { headers: resolvedHeaders as HeadersInit }
: undefined;
const resolvedHeaders = materializeHeaders(command.headers, activeDefinition.name);
const baseOptions = {
requestInit,
authProvider: oauthSession?.provider,
};
const requestInit: RequestInit | undefined = resolvedHeaders
? { headers: resolvedHeaders as HeadersInit }
: undefined;
const streamableTransport = new StreamableHTTPClientTransport(definition.command.url, baseOptions);
const baseOptions = {
requestInit,
authProvider: oauthSession?.provider,
};
const attemptConnect = async () => {
const streamableTransport = new StreamableHTTPClientTransport(command.url, baseOptions);
try {
await this.connectWithAuth(
client,
streamableTransport,
oauthSession,
activeDefinition.name,
options.maxOAuthAttempts
);
return {
client,
transport: streamableTransport,
definition: activeDefinition,
oauthSession,
} as ClientContext;
} catch (error) {
await closeTransportAndWait(this.logger, streamableTransport).catch(() => {});
throw error;
}
};
try {
try {
await this.connectWithAuth(
client,
streamableTransport,
oauthSession,
definition.name,
options.maxOAuthAttempts
);
return {
client,
transport: streamableTransport,
definition,
oauthSession,
};
} catch (error) {
await closeTransportAndWait(this.logger, streamableTransport).catch(() => {});
this.logger.info(`Falling back to SSE transport for '${definition.name}': ${(error as Error).message}`);
const sseTransport = new SSEClientTransport(definition.command.url, {
return await attemptConnect();
} catch (primaryError) {
if (primaryError instanceof UnauthorizedError) {
await oauthSession?.close().catch(() => {});
const promoted = maybeEnableOAuth(activeDefinition, this.logger);
if (promoted && options.maxOAuthAttempts !== 0) {
activeDefinition = promoted;
this.definitions.set(promoted.name, promoted);
continue;
}
}
if (primaryError instanceof Error) {
this.logger.info(`Falling back to SSE transport for '${activeDefinition.name}': ${primaryError.message}`);
}
const sseTransport = new SSEClientTransport(command.url, {
...baseOptions,
});
await this.connectWithAuth(client, sseTransport, oauthSession, definition.name, options.maxOAuthAttempts);
return { client, transport: sseTransport, definition, oauthSession };
try {
await this.connectWithAuth(
client,
sseTransport,
oauthSession,
activeDefinition.name,
options.maxOAuthAttempts
);
return { client, transport: sseTransport, definition: activeDefinition, oauthSession };
} catch (sseError) {
await closeTransportAndWait(this.logger, sseTransport).catch(() => {});
await oauthSession?.close().catch(() => {});
if (sseError instanceof UnauthorizedError && options.maxOAuthAttempts !== 0) {
const promoted = maybeEnableOAuth(activeDefinition, this.logger);
if (promoted) {
activeDefinition = promoted;
this.definitions.set(promoted.name, promoted);
continue;
}
}
throw sseError;
}
}
} catch (error) {
await oauthSession?.close().catch(() => {});
throw error;
}
});
}
@ -366,6 +408,30 @@ class McpRuntime implements Runtime {
}
}
function maybeEnableOAuth(definition: ServerDefinition, logger: RuntimeLogger): ServerDefinition | undefined {
if (definition.auth === 'oauth') {
return undefined;
}
if (definition.command.kind !== 'http') {
return undefined;
}
const isAdHocSource = definition.source && definition.source.kind === 'local' && definition.source.path === '<adhoc>';
if (!isAdHocSource) {
return undefined;
}
const tokenCacheDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
logger.info(`Detected OAuth requirement for '${definition.name}'. Launching browser flow...`);
return {
...definition,
auth: 'oauth',
tokenCacheDir,
};
}
export const __test = {
maybeEnableOAuth,
};
// closeTransportAndWait closes the transport and ensures its backing process exits.
async function closeTransportAndWait(
logger: RuntimeLogger,

View File

@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { type ServerDefinition } from '../src/config.js';
import { __test } from '../src/runtime.js';
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
describe('maybeEnableOAuth', () => {
const baseDefinition: ServerDefinition = {
name: 'adhoc-server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
source: { kind: 'local', path: '<adhoc>' },
};
it('returns an updated definition for ad-hoc HTTP servers', () => {
const updated = __test.maybeEnableOAuth(baseDefinition, logger as never);
expect(updated).toBeDefined();
expect(updated?.auth).toBe('oauth');
expect(updated?.tokenCacheDir).toContain('adhoc-server');
expect(logger.info).toHaveBeenCalled();
});
it('does not mutate non-ad-hoc servers', () => {
const def: ServerDefinition = {
name: 'local-server',
command: { kind: 'http', url: new URL('https://example.com') },
source: { kind: 'local', path: '/tmp/config.json' },
};
const updated = __test.maybeEnableOAuth(def, logger as never);
expect(updated).toBeUndefined();
});
});