diff --git a/CHANGELOG.md b/CHANGELOG.md index 323d4b2..077de82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4ea6680..cb5530b 100644 --- a/README.md +++ b/README.md @@ -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 ` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md). ## Quick Start diff --git a/docs/adhoc.md b/docs/adhoc.md index 0658187..97a88b3 100644 --- a/docs/adhoc.md +++ b/docs/adhoc.md @@ -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: `` 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 ` 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. diff --git a/docs/spec.md b/docs/spec.md index 315e244..825d4a8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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/` when none is provided). diff --git a/src/runtime.ts b/src/runtime.ts index 59ec4db..addb6f4 100644 --- a/src/runtime.ts +++ b/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 { // 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 === ''; + 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, diff --git a/tests/runtime-oauth-detection.test.ts b/tests/runtime-oauth-detection.test.ts new file mode 100644 index 0000000..1cfeaff --- /dev/null +++ b/tests/runtime-oauth-detection.test.ts @@ -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: '' }, + }; + + 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(); + }); +});