Auto-detect OAuth for ad-hoc servers
This commit is contained in:
parent
6eeb9ccec1
commit
7a17aaa44b
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 don’t 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.
|
||||
|
||||
@ -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).
|
||||
|
||||
158
src/runtime.ts
158
src/runtime.ts
@ -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,
|
||||
|
||||
36
tests/runtime-oauth-detection.test.ts
Normal file
36
tests/runtime-oauth-detection.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user