Support positional server refs for generate-cli
This commit is contained in:
parent
027dac690c
commit
e7a282fddd
@ -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.
|
||||
|
||||
|
||||
@ -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`.
|
||||
```
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
117
src/cli.ts
117
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<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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user