diff --git a/README.md b/README.md index 82e759e..e0e7400 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ npx mcporter call 'linear.create_comment(issueId: "ENG-123", body: "Looks good!" npx mcporter list npx mcporter list context7 --schema npx mcporter list https://mcp.linear.app/mcp --all-parameters +npx mcporter list shadcn.io/api/mcp.getComponents # URL + tool suffix auto-resolves npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz ``` @@ -115,6 +116,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q npx mcporter call chrome-devtools.take_snapshot npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")' npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me +npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues npx mcporter linear.list_issues # shorthand: infers `call` ``` @@ -269,6 +271,9 @@ npx mcporter generate-cli \ - `--runtime bun|node` picks the runtime for generated code (Bun required for `--compile`). - Add `--compile` to emit a Bun-compiled binary; MCPorter cleans up intermediate bundles when you omit `--bundle`. - Use `--from ` (optionally `--dry-run`) to regenerate an existing CLI using its embedded metadata. +- Prefer a positional shorthand if the server already lives in your config/imports: + `npx mcporter generate-cli linear --bundle dist/linear.js`. +- `--server`/`--command` accept HTTP URLs, optional `.tool` suffixes, and even scheme-less hosts (`shadcn.io/api/mcp.getComponents`). Every artifact embeds regeneration metadata (generator version, resolved server definition, invocation flags). Use: @@ -292,6 +297,7 @@ npx mcporter emit-ts linear --mode client --out clients/linear.ts - `--mode types` (default) produces a `.d.ts` interface you can import anywhere. - `--mode client` emits the `.d.ts` **and** a `.ts` helper that wraps `createRuntime` / `createServerProxy` for you. - Add `--include-optional` whenever you want every optional field spelled out (mirrors `mcporter list --all-parameters`). +- The `` argument also understands HTTP URLs and selectors with `.tool` suffixes or missing protocols—mirroring the main CLI. See [docs/emit-ts.md](docs/emit-ts.md) for the full flag reference plus inline snapshots of the emitted files. diff --git a/docs/cli-generator.md b/docs/cli-generator.md index ee99cf5..650124b 100644 --- a/docs/cli-generator.md +++ b/docs/cli-generator.md @@ -73,6 +73,8 @@ chmod +x context7 - `--compile [path]` implies bundling and invokes `bun build --compile` to create the native executable (Bun only). When you omit the path, the compiled binary inherits the server name. - Use `--server '{...}'` when you need advanced configuration (headers, env vars, stdio commands, OAuth metadata). - Omit `--name` to let mcporter infer it from the command URL (for example, `https://mcp.context7.com/mcp` becomes `context7`). +- When targeting an existing config entry, you can skip `--server` and pass the name as a positional argument: + `npx mcporter generate-cli linear --bundle dist/linear.js`. ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1c6577b..88b6921 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -36,6 +36,9 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits `regenerate-cli` behavior, must point at an existing CLI). - `--dry-run` – print the resolved `mcporter generate-cli ...` command without executing (requires `--from`). + - Positional shorthand: `npx mcporter generate-cli linear` uses the configured + `linear` definition; `npx mcporter generate-cli https://example.com/mcp` + treats the URL as an ad-hoc server definition. ## `mcporter emit-ts ` - Emits TypeScript definitions (and optionally a ready-to-use client) describing diff --git a/src/cli.ts b/src/cli.ts index 1250457..6265d71 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { handleEmitTs } from './cli/emit-ts-command.js'; import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js'; import { CliUsageError } from './cli/errors.js'; import { extractGeneratorFlags } from './cli/generate/flag-parser.js'; +import { extractHttpServerTarget, looksLikeHttpUrl, normalizeHttpUrlCandidate } from './cli/http-utils.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'; @@ -225,7 +226,8 @@ function parseGenerateFlags(args: string[]): GenerateFlags { continue; } if (token === '--command') { - command = expectValue(token, args[index + 1]); + const value = expectValue(token, args[index + 1]); + command = normalizeCommandInput(value); args.splice(index, 2); continue; } @@ -271,7 +273,10 @@ function parseGenerateFlags(args: string[]): GenerateFlags { args.splice(index, 1); continue; } - throw new Error(`Unknown flag '${token}' for generate-cli.`); + if (token.startsWith('--')) { + throw new Error(`Unknown flag '${token}' for generate-cli.`); + } + index += 1; } return { @@ -298,6 +303,11 @@ function expectValue(flag: string, value: string | undefined): string { return value; } +function normalizeCommandInput(value: string): string { + const target = extractHttpServerTarget(value); + return target ?? value; +} + // handleGenerateCli parses flags and generates the requested standalone CLI. export async function handleGenerateCli(args: string[], globalFlags: FlagMap): Promise { const parsed = parseGenerateFlags(args); @@ -308,6 +318,21 @@ export async function handleGenerateCli(args: string[], globalFlags: FlagMap): P throw new Error('--dry-run currently requires --from .'); } + if (!parsed.server && !parsed.command && !parsed.from) { + const positional = args.find((token) => token && !token.startsWith('--')); + if (positional) { + const position = args.indexOf(positional); + if (position !== -1) { + args.splice(position, 1); + } + if (looksLikeHttpUrl(positional) || positional.includes('://')) { + parsed.command = positional; + } else { + parsed.server = positional; + } + } + } + if (parsed.from) { const { metadata, request } = await resolveGenerateRequestFromArtifact(parsed, globalFlags); if (parsed.dryRun) { @@ -493,41 +518,50 @@ function inferNameFromCommand(command: string): string | undefined { if (!trimmed) { return undefined; } + const candidate = normalizeHttpUrlCandidate(trimmed) ?? trimmed; try { - const url = new URL(trimmed); - const genericHosts = new Set(['www', 'api', 'mcp', 'service', 'services', 'app', 'localhost']); - const knownTlds = new Set(['com', 'net', 'org', 'io', 'ai', 'app', 'dev', 'co', 'cloud']); - const parts = url.hostname.split('.').filter(Boolean); - const filtered = parts.filter((part) => { - const lower = part.toLowerCase(); - if (genericHosts.has(lower)) { - return false; - } - if (knownTlds.has(lower)) { - return false; - } - if (/^\d+$/.test(part)) { - return false; - } - return true; - }); - if (filtered.length > 0) { - const last = filtered[filtered.length - 1]; - if (last) { - return last; - } - } - const segments = url.pathname.split('/').filter(Boolean); - const firstSegment = segments[0]; - if (firstSegment) { - return firstSegment.replace(/[^a-zA-Z0-9-_]/g, '-'); + const url = new URL(candidate); + const derived = deriveNameFromUrl(url); + if (derived) { + return derived; } } catch { // not a URL; fall through to filesystem heuristics } const firstToken = trimmed.split(/\s+/)[0] ?? trimmed; - const candidate = firstToken.split(/[\\/]/).pop() ?? firstToken; - return candidate.replace(/\.[a-z0-9]+$/i, ''); + const candidateToken = firstToken.split(/[\\/]/).pop() ?? firstToken; + return candidateToken.replace(/\.[a-z0-9]+$/i, ''); +} + +function deriveNameFromUrl(url: URL): string | undefined { + const genericHosts = new Set(['www', 'api', 'mcp', 'service', 'services', 'app', 'localhost']); + const knownTlds = new Set(['com', 'net', 'org', 'io', 'ai', 'app', 'dev', 'co', 'cloud']); + const parts = url.hostname.split('.').filter(Boolean); + const filtered = parts.filter((part) => { + const lower = part.toLowerCase(); + if (genericHosts.has(lower)) { + return false; + } + if (knownTlds.has(lower)) { + return false; + } + if (/^\d+$/.test(part)) { + return false; + } + return true; + }); + if (filtered.length > 0) { + const last = filtered[filtered.length - 1]; + if (last) { + return last; + } + } + const segments = url.pathname.split('/').filter(Boolean); + const firstSegment = segments[0]; + if (firstSegment) { + return firstSegment.replace(/[^a-zA-Z0-9-_]/g, '-'); + } + return undefined; } // buildGenerateCliCommand reconstructs the generate-cli invocation for logging/dry runs. @@ -651,13 +685,16 @@ export async function handleAuth(runtime: Awaited { }); describe('inspect/generate CLI artifacts', () => { + it('normalizes HTTP selectors passed to --command', async () => { + const args = ['--command', 'shadcn.io/api/mcp.getComponents', '--name', 'demo', '--output', 'out.ts']; + await handleGenerateCli(args, {}); + expect(generateCliMock).toHaveBeenCalledTimes(1); + const invocation = generateCliMock.mock.calls[0]?.[0]; + expect(invocation?.serverRef).toContain('shadcn.io/api/mcp'); + }); + it('prints metadata summary for inspect-cli', async () => { const artifactPath = await writeMetadataFixture('binary'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -96,6 +104,40 @@ describe('inspect/generate CLI artifacts', () => { logSpy.mockRestore(); }); + it('allows positional server references', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleGenerateCli(['linear'], {}); + + expect(generateCliMock).toHaveBeenCalledTimes(1); + const invocation = generateCliMock.mock.calls[0]?.[0]; + expect(invocation).toMatchObject({ + serverRef: 'linear', + }); + expect(logSpy.mock.calls.some((call) => String(call[0]).includes('Generated CLI'))).toBe(true); + + logSpy.mockRestore(); + }); + + it('treats positional http urls as ad-hoc commands', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const commandUrl = 'https://www.shadcn.io/api/mcp'; + + await handleGenerateCli([commandUrl, '--name', 'shadcn'], {}); + + expect(generateCliMock).toHaveBeenCalledTimes(1); + const invocation = generateCliMock.mock.calls[0]?.[0]; + expect(invocation.serverRef).toBe( + JSON.stringify({ + name: 'shadcn', + command: commandUrl, + }) + ); + expect(logSpy.mock.calls.some((call) => String(call[0]).includes('Generated CLI'))).toBe(true); + + logSpy.mockRestore(); + }); + it('falls back to legacy metadata files when present', async () => { await fs.mkdir(tmpDir, { recursive: true }); const artifactPath = path.join(tmpDir, 'legacy-artifact');