Support positional server refs for generate-cli

This commit is contained in:
Peter Steinberger 2025-11-07 03:08:33 +00:00
parent 027dac690c
commit e7a282fddd
5 changed files with 128 additions and 42 deletions

View File

@ -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 <artifact>` (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 `<server>` 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.

View File

@ -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`.
```

View File

@ -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 <server>`
- Emits TypeScript definitions (and optionally a ready-to-use client) describing

View File

@ -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<void> {
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 <artifact>.');
}
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<ReturnType<typeof createRuntim
}
let ephemeralSpec: EphemeralServerSpec | undefined = extractEphemeralServerFlags(args);
let target = args.shift();
if (target && looksLikeHttpUrl(target)) {
const reused = findServerByHttpUrl(runtime.getDefinitions(), target);
if (reused) {
target = reused;
} else if (!ephemeralSpec) {
ephemeralSpec = { httpUrl: target };
target = undefined;
if (target) {
const normalizedTarget = normalizeHttpUrlCandidate(target);
if (normalizedTarget) {
const reused = findServerByHttpUrl(runtime.getDefinitions(), normalizedTarget);
if (reused) {
target = reused;
} else if (!ephemeralSpec) {
ephemeralSpec = { httpUrl: normalizedTarget };
target = undefined;
}
}
}
@ -719,7 +756,3 @@ function shouldRetryAuthError(error: unknown): boolean {
}
return /unauthorized|invalid[_-]?token|\b(401|403)\b/i.test(message);
}
function looksLikeHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}

View File

@ -40,6 +40,14 @@ afterEach(async () => {
});
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');