diff --git a/CHANGELOG.md b/CHANGELOG.md index d00a53e..99ded81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Guaranteed that default listings always show at least five parameters (even if every field is optional) before summarising the rest, and added compact summaries (`// optional (N): …`). - Added `src/cli/list-detail-helpers.ts` plus dedicated unit tests (`tests/list-detail-helpers.test.ts`) covering wrapping, param selection, and optional summaries; introduced an inline snapshot test for a complex Linear server to prevent regressions in the CLI formatter. - Exported the identifier normalization helpers so other modules can reuse the shared Levenshtein logic without duplicate implementations. +- Added a shared `extractEphemeralServerFlags` helper so `list`, `call`, and `auth` parse ad-hoc transports consistently, extended `mcporter auth` to accept bare URLs/`--http-url`/`--stdio`, and taught single-server listings to hint `mcporter auth https://…` when a 401 occurs. Docs (`README.md`, `docs/adhoc.md`, `docs/local.md`, `docs/call-heuristic.md`) and new tests (`tests/cli-auth.test.ts`, `tests/cli-ephemeral-flags.test.ts`, expanded `tests/cli-list.test.ts`) cover the workflow. ## [0.3.0] - 2025-11-06 diff --git a/README.md b/README.md index 5024337..28fd890 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ npx mcporter list https://mcp.linear.app/mcp --all-parameters npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz ``` -You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. The CLI infers a stable name for caching OAuth tokens and can merge the generated entry into a config file whenever you pass `--persist`. Full details live in [docs/adhoc.md](docs/adhoc.md). +You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. Follow up with `mcporter auth https://…` (or the same flag set) to finish OAuth without editing config. Full details live in [docs/adhoc.md](docs/adhoc.md). Single-server listings now read like a TypeScript header file so you can copy/paste the signature straight into `mcporter call`: @@ -85,7 +85,7 @@ Helpful flags: - `--tail-log` -- stream the last 20 lines of any log files referenced by the tool response. - `--output ` or `--raw` -- control formatted output (defaults to pretty-printed auto detection). - `--all-parameters` -- show every schema field when listing a server (default output shows at least five parameters plus a summary of the rest). -- `--http-url ` / `--stdio "command …"` -- describe an ad-hoc MCP server inline (pair with `--env KEY=value`, `--cwd`, `--name`, and `--persist ` as needed). +- `--http-url ` / `--stdio "command …"` -- describe an ad-hoc MCP server inline (pair with `--env KEY=value`, `--cwd`, `--name`, and `--persist ` as needed). These flags now work with `mcporter auth` too, so `mcporter auth https://mcp.example.com/mcp` just works. - For OAuth-protected servers such as `vercel`, run `npx mcporter auth vercel` once to complete login. > Tip: You can skip the verb entirely—`mcporter firecrawl` automatically runs `mcporter list firecrawl`, and dotted tokens like `mcporter linear.list_issues` dispatch to the call command (typo fixes included). diff --git a/docs/adhoc.md b/docs/adhoc.md index 6b7659e..0658187 100644 --- a/docs/adhoc.md +++ b/docs/adhoc.md @@ -23,13 +23,14 @@ You can also pass a bare URL as the selector (`mcporter list https://mcp.linear. - Otherwise we derive a slug: - HTTP: `` plus a sanitized path fragment (e.g. `mcp-linear-app-mcp`). - STDIO: executable basename + script (`node-mcp-server`). -- The inferred name is printed so you know what to reuse later (`mcporter auth `). +- 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. ## Auth & Persistence - OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions. +- `mcporter auth` accepts the same `--http-url/--stdio` flags (and even bare URLs), so you can immediately re-run `mcporter auth https://…` after a 401 without touching a config file. - Nothing is written to disk unless you pass `--persist /path/to/config.json`. When set, we merge the generated definition into that file (creating it if necessary) so future runs can rely on the standard config pipeline. ## Safety Nets diff --git a/docs/call-heuristic.md b/docs/call-heuristic.md index 041768b..4ee881b 100644 --- a/docs/call-heuristic.md +++ b/docs/call-heuristic.md @@ -22,3 +22,4 @@ - `mcporter list ` now applies the same edit-distance heuristic to server names. If you type `vercek`, the CLI auto-corrects to `vercel` (and logs `[mcporter] Auto-corrected server name to vercel (input: vercek).`). - When the typo is too large, we keep the original failure but emit a hint: `[mcporter] Did you mean linear?` followed by the usual “Unknown MCP server …” line. This avoids giant stack traces while pointing to the right name. - The heuristic considers every configured server (including ad-hoc ones registered via `--http-url/--stdio`). Tests covering this behaviour live in `tests/cli-list.test.ts`. +- `mcporter auth` shares the same routing logic, so `mcporter auth https://mcp.example.com/mcp` (or even `mcporter auth vercek`) will spin up the temporary definition, auto-correct close names, and launch OAuth without touching the config file. diff --git a/docs/local.md b/docs/local.md index 8bacf34..5bee89a 100644 --- a/docs/local.md +++ b/docs/local.md @@ -15,6 +15,9 @@ pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react # auth flow pnpm exec tsx src/cli.ts auth vercel + +# ad-hoc auth +pnpm exec tsx src/cli.ts auth https://mcp.supabase.com/mcp ``` These invocations match the `pnpm mcporter:*` scripts and are ideal when you’re iterating on TypeScript without rebuilding. diff --git a/src/cli.ts b/src/cli.ts index 5d75eb9..78d842a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node import fsPromises from 'node:fs/promises'; import { handleCall as runHandleCall } from './cli/call-command.js'; +import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './cli/adhoc-server.js'; import { inferCommandRouting } from './cli/command-inference.js'; +import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js'; import { handleList } from './cli/list-command.js'; import { formatSourceSuffix } from './cli/list-format.js'; import { getActiveLogger, getActiveLogLevel, logError, logInfo, logWarn, setLogLevel } from './cli/logger-context.js'; @@ -728,16 +730,38 @@ if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') { }); } // handleAuth clears cached tokens and executes standalone OAuth flows. -async function handleAuth(runtime: Awaited>, args: string[]): Promise { +export async function handleAuth(runtime: Awaited>, args: string[]): Promise { // Peel off optional flags before we consume positional args. const resetIndex = args.indexOf('--reset'); const shouldReset = resetIndex !== -1; if (shouldReset) { args.splice(resetIndex, 1); } - const target = args.shift(); + let ephemeralSpec: EphemeralServerSpec | undefined = extractEphemeralServerFlags(args); + let target = args.shift(); + if (!ephemeralSpec && target && looksLikeHttpUrl(target)) { + ephemeralSpec = { httpUrl: target }; + target = undefined; + } + + if (ephemeralSpec && target && !looksLikeHttpUrl(target)) { + ephemeralSpec = { ...ephemeralSpec, name: ephemeralSpec.name ?? target }; + } + + let ephemeralResolution: ReturnType | undefined; + if (ephemeralSpec) { + ephemeralResolution = resolveEphemeralServer(ephemeralSpec); + runtime.registerDefinition(ephemeralResolution.definition, { overwrite: true }); + if (ephemeralSpec.persistPath) { + await persistEphemeralServer(ephemeralResolution, ephemeralSpec.persistPath); + } + if (!target) { + target = ephemeralResolution.name; + } + } + if (!target) { - throw new Error('Usage: mcporter auth '); + throw new Error('Usage: mcporter auth [--http-url | --stdio ]'); } const definition = runtime.getDefinition(target); @@ -762,3 +786,7 @@ async function handleAuth(runtime: Awaited>, ar throw new Error(`Failed to authorize '${target}': ${message}`); } } + +function looksLikeHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} diff --git a/src/cli/call-command.ts b/src/cli/call-command.ts index bedb58c..a73c55b 100644 --- a/src/cli/call-command.ts +++ b/src/cli/call-command.ts @@ -2,6 +2,7 @@ import { createCallResult } from '../result-utils.js'; import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './adhoc-server.js'; import { parseCallExpressionFragment } from './call-expression-parser.js'; import { chooseClosestIdentifier, normalizeIdentifier } from './identifier-helpers.js'; +import { extractEphemeralServerFlags } from './ephemeral-flags.js'; import { type OutputFormat, printCallOutput, tailLogIfRequested } from './output-utils.js'; import { dumpActiveHandles } from './runtime-debug.js'; import { dimText } from './terminal.js'; @@ -25,13 +26,9 @@ function isOutputFormat(value: string): value is OutputFormat { export function parseCallArguments(args: string[]): CallArgsParseResult { // Maintain backwards compatibility with legacy positional + key=value forms. const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' }; + const ephemeral = extractEphemeralServerFlags(args); + result.ephemeral = ephemeral; const positional: string[] = []; - const ensureEphemeral = (): EphemeralServerSpec => { - if (!result.ephemeral) { - result.ephemeral = {}; - } - return result.ephemeral; - }; let index = 0; while (index < args.length) { const token = args[index]; @@ -75,91 +72,6 @@ export function parseCallArguments(args: string[]): CallArgsParseResult { index += 1; continue; } - if (token === '--http-url') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--http-url' requires a value."); - } - ensureEphemeral().httpUrl = value; - index += 2; - continue; - } - if (token === '--allow-http') { - ensureEphemeral().allowInsecureHttp = true; - index += 1; - continue; - } - if (token === '--stdio') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--stdio' requires a value."); - } - ensureEphemeral().stdioCommand = value; - index += 2; - continue; - } - if (token === '--stdio-arg') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--stdio-arg' requires a value."); - } - const ephemeral = ensureEphemeral(); - ephemeral.stdioArgs = [...(ephemeral.stdioArgs ?? []), value]; - index += 2; - continue; - } - if (token === '--env') { - const value = args[index + 1]; - if (!value || !value.includes('=')) { - throw new Error("Flag '--env' requires KEY=value."); - } - const [key, ...rest] = value.split('='); - if (!key) { - throw new Error("Flag '--env' requires KEY=value."); - } - const ephemeral = ensureEphemeral(); - const envMap = ephemeral.env ? { ...ephemeral.env } : {}; - envMap[key] = rest.join('='); - ephemeral.env = envMap; - index += 2; - continue; - } - if (token === '--cwd') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--cwd' requires a value."); - } - ensureEphemeral().cwd = value; - index += 2; - continue; - } - if (token === '--name') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--name' requires a value."); - } - ensureEphemeral().name = value; - index += 2; - continue; - } - if (token === '--description') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--description' requires a value."); - } - ensureEphemeral().description = value; - index += 2; - continue; - } - if (token === '--persist') { - const value = args[index + 1]; - if (!value) { - throw new Error("Flag '--persist' requires a value."); - } - ensureEphemeral().persistPath = value; - index += 2; - continue; - } if (token === '--yes') { index += 1; continue; diff --git a/src/cli/ephemeral-flags.ts b/src/cli/ephemeral-flags.ts new file mode 100644 index 0000000..04a0a21 --- /dev/null +++ b/src/cli/ephemeral-flags.ts @@ -0,0 +1,128 @@ +import type { EphemeralServerSpec } from './adhoc-server.js'; + +interface ExtractOptions { + allowPersist?: boolean; +} + +// extractEphemeralServerFlags scans argv for ad-hoc server descriptors (HTTP/STDIO/env/etc.) +// and removes them so higher-level parsers can focus on command-specific flags. +export function extractEphemeralServerFlags( + args: string[], + options: ExtractOptions = {} +): EphemeralServerSpec | undefined { + let spec: EphemeralServerSpec | undefined; + const ensureSpec = (): EphemeralServerSpec => { + if (!spec) { + spec = {}; + } + return spec; + }; + + const allowPersist = options.allowPersist ?? true; + let index = 0; + while (index < args.length) { + const token = args[index]; + if (!token) { + index += 1; + continue; + } + + if (token === '--http-url') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--http-url' requires a value."); + } + ensureSpec().httpUrl = value; + args.splice(index, 2); + continue; + } + + if (token === '--allow-http') { + ensureSpec().allowInsecureHttp = true; + args.splice(index, 1); + continue; + } + + if (token === '--stdio') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--stdio' requires a value."); + } + ensureSpec().stdioCommand = value; + args.splice(index, 2); + continue; + } + + if (token === '--stdio-arg') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--stdio-arg' requires a value."); + } + const current = ensureSpec(); + current.stdioArgs = [...(current.stdioArgs ?? []), value]; + args.splice(index, 2); + continue; + } + + if (token === '--env') { + const value = args[index + 1]; + if (!value || !value.includes('=')) { + throw new Error("Flag '--env' requires KEY=value."); + } + const [key, ...rest] = value.split('='); + if (!key) { + throw new Error("Flag '--env' requires KEY=value."); + } + const current = ensureSpec(); + const envMap = current.env ? { ...current.env } : {}; + envMap[key] = rest.join('='); + current.env = envMap; + args.splice(index, 2); + continue; + } + + if (token === '--cwd') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--cwd' requires a value."); + } + ensureSpec().cwd = value; + args.splice(index, 2); + continue; + } + + if (token === '--name') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--name' requires a value."); + } + ensureSpec().name = value; + args.splice(index, 2); + continue; + } + + if (token === '--description') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--description' requires a value."); + } + ensureSpec().description = value; + args.splice(index, 2); + continue; + } + + if (allowPersist && token === '--persist') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--persist' requires a value."); + } + ensureSpec().persistPath = value; + args.splice(index, 2); + continue; + } + + index += 1; + } + + return spec; +} diff --git a/src/cli/list-format.ts b/src/cli/list-format.ts index 42e77e3..6f1fd19 100644 --- a/src/cli/list-format.ts +++ b/src/cli/list-format.ts @@ -76,7 +76,8 @@ export function formatSourceSuffix(source: ServerSource | undefined, inline = fa export function classifyListError( error: unknown, serverName: string, - _timeoutSeconds: number + _timeoutSeconds: number, + options?: { authCommand?: string } ): { colored: string; summary: string; @@ -84,8 +85,9 @@ export function classifyListError( authCommand?: string; } { if (error instanceof UnauthorizedError) { - const note = yellowText(`auth required — run 'mcporter auth ${serverName}'`); - return { colored: note, summary: 'auth required', category: 'auth', authCommand: `mcporter auth ${serverName}` }; + const authCommand = options?.authCommand ?? `mcporter auth ${serverName}`; + const note = yellowText(`auth required — run '${authCommand}'`); + return { colored: note, summary: 'auth required', category: 'auth', authCommand }; } const rawMessage = @@ -104,8 +106,9 @@ export function classifyListError( normalized.includes('invalid_token') || normalized.includes('forbidden') ) { - const note = yellowText(`auth required — run 'mcporter auth ${serverName}'`); - return { colored: note, summary: 'auth required', category: 'auth', authCommand: `mcporter auth ${serverName}` }; + const authCommand = options?.authCommand ?? `mcporter auth ${serverName}`; + const note = yellowText(`auth required — run '${authCommand}'`); + return { colored: note, summary: 'auth required', category: 'auth', authCommand }; } if ( diff --git a/tests/cli-auth.test.ts b/tests/cli-auth.test.ts new file mode 100644 index 0000000..f98d798 --- /dev/null +++ b/tests/cli-auth.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +process.env.MCPORTER_DISABLE_AUTORUN = '1'; +const cliModulePromise = import('../src/cli.js'); + +const createRuntimeDouble = () => { + const definitions = new Map>(); + const registerDefinition = vi.fn((definition: Record) => { + definitions.set(definition.name as string, { ...definition }); + }); + const getDefinition = vi.fn((name: string) => { + const definition = definitions.get(name); + if (!definition) { + throw new Error(`Unknown MCP server '${name}'.`); + } + return definition; + }); + const listTools = vi.fn().mockResolvedValue([{ name: 'ok' }]); + const runtime = { + registerDefinition, + getDefinition, + getDefinitions: () => Array.from(definitions.values()), + listTools, + } as unknown as Awaited>; + return { runtime, listTools }; +}; + +describe('mcporter auth ad-hoc support', () => { + it('registers ad-hoc HTTP servers via --http-url', async () => { + const { handleAuth } = await cliModulePromise; + const { runtime, listTools } = createRuntimeDouble(); + + await handleAuth(runtime, ['--http-url', 'https://mcp.deepwiki.com/sse']); + + expect(listTools).toHaveBeenCalledWith('mcp-deepwiki-com-sse', { autoAuthorize: true }); + }); + + it('accepts bare URLs as the auth target', async () => { + const { handleAuth } = await cliModulePromise; + const { runtime, listTools } = createRuntimeDouble(); + + await handleAuth(runtime, ['https://mcp.supabase.com/mcp']); + + expect(listTools).toHaveBeenCalledWith('mcp-supabase-com-mcp', { autoAuthorize: true }); + }); +}); diff --git a/tests/cli-ephemeral-flags.test.ts b/tests/cli-ephemeral-flags.test.ts new file mode 100644 index 0000000..d3f317c --- /dev/null +++ b/tests/cli-ephemeral-flags.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { extractEphemeralServerFlags } from '../src/cli/ephemeral-flags.js'; + +describe('extractEphemeralServerFlags', () => { + it('parses HTTP URLs and env overrides', () => { + const args = ['--http-url', 'https://mcp.example.com/mcp', '--env', 'TOKEN=abc', 'list']; + const spec = extractEphemeralServerFlags(args); + expect(spec).toEqual({ httpUrl: 'https://mcp.example.com/mcp', env: { TOKEN: 'abc' } }); + expect(args).toEqual(['list']); + }); + + it('captures stdio commands and additional args', () => { + const args = ['--stdio', 'bun run ./server.ts', '--stdio-arg', '--watch', 'call']; + const spec = extractEphemeralServerFlags(args); + expect(spec).toEqual({ stdioCommand: 'bun run ./server.ts', stdioArgs: ['--watch'] }); + expect(args).toEqual(['call']); + }); +});