Add emit-ts improvements and shared CLI infrastructure

This commit is contained in:
Peter Steinberger 2025-11-07 01:16:14 +00:00
parent e880dc48bb
commit 1e29d71305
20 changed files with 432 additions and 186 deletions

53
docs/cli-reference.md Normal file
View File

@ -0,0 +1,53 @@
# mcporter CLI Reference
A quick reference for the primary `mcporter` subcommands. Each command inherits
`--config <file>` and `--root <dir>` to override where servers are loaded from.
## `mcporter list [server]`
- Without arguments, lists every configured server (with live discovery + brief
status).
- With a server name, prints TypeScript-style signatures for each tool, doc
comments, and optional summaries.
- Flags:
- `--all-parameters` include every optional parameter in the signature.
- `--schema` pretty-print the JSON schema for each tool.
- `--timeout <ms>` per-server timeout when enumerating all servers.
## `mcporter call <server.tool>`
- Invokes a tool once and prints the response; supports positional arguments via
pseudo-TS syntax and `--arg` flags.
- Useful flags:
- `--server`, `--tool` alternate way to target a tool.
- `--timeout <ms>` override call timeout (defaults to `CALL_TIMEOUT_MS`).
- `--output text|markdown|json|raw` choose how to render the `CallResult`.
- `--tail-log` stream tail output when the tool returns log handles.
## `mcporter generate-cli`
- Produces a standalone CLI for a single MCP server (optionally bundling or
compiling with Bun).
- Key flags:
- `--server <name>` (or inline JSON) choose the server definition.
- `--output <path>` where to write the TypeScript template.
- `--bundle <path>` emit a bundle (Node/Bun) ready for `bun x`.
- `--compile <path>` compile with Bun (implies `--runtime bun`).
- `--timeout <ms>` / `--runtime node|bun` shared via the generator flag
parser so defaults stay consistent.
## `mcporter emit-ts <server>`
- Emits TypeScript definitions (and optionally a ready-to-use client) describing
a servers tools. This reuses the same formatter as `mcporter list` so doc
comments, signatures, and examples stay in sync.
- Modes:
- `--mode types --out <file.d.ts>` (default) export an interface whose
methods return `Promise<CallResult>`, with doc comments and optional
summaries.
- `--mode client --out <file.ts>` emit both the interface (`<file>.d.ts`)
and a factory that wraps `createServerProxy`, returning objects whose
methods resolve to `CallResult`.
- Other flags:
- `--include-optional` (alias `--all-parameters`) show every optional field.
- `--types-out <file>` override where the `.d.ts` sits when using client
mode.
For more detail (behavioral nuances, OAuth flows, etc.), see `docs/spec.md` and
command-specific docs under `docs/`.

View File

@ -1,66 +0,0 @@
# CLI ↔ Generator Code Reuse Plan
The goals below align `mcporter list`, the TypeScript CLI generator, and any future TS export modes so we build (and test) formatting logic once.
## 1. Shared Example Rendering
- **Problem**: `list-detail-helpers.ts` shortens `mcporter call` examples, but `generate/template.ts` still prints `--flag` examples via `buildExampleInvocation`.
- **Plan**:
1. Export a non-colored `formatExampleBlock()` utility (and the internal `truncateExample()` helper) from `list-detail-helpers.ts`.
2. Import that helper inside `renderToolCommand()` and replace `buildExampleInvocation` with the shared function-call output.
3. Drop the duplicate `buildExampleInvocation/pickExampleValue` logic once Commander help uses the shared examples.
4. Update the generator tests (in `tests/generate-cli.test.ts`) to expect the new syntax.
## 2. Optional Summary in Generated Help *(Completed)*
- **Problem**: The runtime CLI prints `// optional (n): …` while generated CLIs enumerate every flag.
- **What we did**:
1. Reused `selectDisplayOptions()` so both CLI/GH generator decide which params to display.
2. Added `formatOptionalSummary()` + `buildToolDoc()` wiring so each surface appends the same `// optional (…)` hint only when options were hidden.
3. Updated `renderToolCommand()` to include the shared hint via `.addHelpText('afterAll', …)` and aligned tests.
- **Next**: No further action unless we change the minimum-visible threshold.
## 3. Consolidate Example Literal Selection *(Completed)*
- **Problem**: CLI used bespoke `buildExampleLiteral`/`buildFallbackLiteral` logic, while generator helpers guessed examples via `buildExampleValue`, so the call expressions could diverge.
- **What we did**:
1. Moved the literal + fallback logic into `pickExampleLiteral()` / `buildFallbackLiteral()` exported from `src/cli/generate/tools.ts`.
2. `buildToolDoc` now imports those helpers, so both `mcporter list` and generated CLIs share the same example arguments.
3. Added unit tests in `tests/generate-cli-helpers.test.ts` covering enums, arrays, and ID/url fallbacks to keep behavior locked.
- **Next**: Consider reusing these helpers for any future docs/export modes that show sample invocations.
## 4. Usage String Builder Parity *(Completed)*
- **Problem**: Commander `.usage()` strings were hand-built (and always listed every flag) while `mcporter list` used the pseudo-TS formatter, so the two could diverge.
- **What we did**:
1. Added `formatFlagUsage()` + `flagExtras` support inside `buildToolDoc`, so the same selector that powers TS signatures now emits the flag-based usage line.
2. Updated `renderToolCommand()` to consume `doc.flagUsage` for both `.summary()` and `.usage()`; optional summaries stay unified through `buildToolDoc`.
3. Added helper unit tests covering mixed required/optional flags and extra entries (e.g., `--raw <json>`).
- **Next**: Expose the shared usage string in `mcporter list` once we add a `--flags` view.
## 5. ToolDocModel Abstraction *(Completed)*
- **Problem**: The runtime CLI and generator still built flag labels + option descriptions separately, so changes to detail formatting required touching multiple files.
- **What we did**:
1. Expanded `ToolDocModel` with `optionDocs` + `formatFlagLabel()`, so each tools flag label/description is computed once inside `buildToolDoc`.
2. Updated `renderToolCommand()` to consume `doc.optionDocs` instead of reassembling strings, leaving only the parser wiring as generator-specific.
3. Added assertions in `tests/list-detail-helpers.test.ts` for the new metadata, keeping the abstraction covered.
- **Next**: With the model centralised, future surfaces (e.g., `--emit-ts`) can render signatures/examples/options straight from `buildToolDoc`.
## 6. `emit-ts` Export Mode *(Completed)*
- **Goal**: Provide a typed contract (and optional client) for each MCP server so agents/tools no longer scrape CLI output.
- **What we did**:
1. Added `mcporter emit-ts <server>` with `--mode types|client`, auto-overwriting targets and deriving `.d.ts` names for client mode.
2. Reused `buildToolDoc` + new templates to emit interfaces (promisified signatures + doc comments) and executable wrappers that return `CallResult` objects.
3. Added `tests/emit-ts.test.ts` to snapshot the templates and run the command end-to-end with a stub runtime; documented the workflow in `docs/emit-ts.md`.
- **Next**: Consider supporting per-tool filters and shared schema maps inside the generated client for faster cold starts.
---
Sequencing recommendation:
1. Implement shared example helper (small change, immediate parity). **Done** `list-detail-helpers.ts` now exports `formatExampleBlock`, `formatCallExpressionExample`, & the generator consumes them.
2. Extract `ToolDocModel` + optional summary builder. **Done** `buildToolDoc` in `src/cli/list-detail-helpers.ts` now feeds both `handleList` and `renderToolCommand`.
3. Update generator to consume the shared helpers (examples + optional summary + signatures). **In progress** signatures/examples unified; `ToolDocModel` still pending.
4. Add unit tests for the new helper module.
5. Build the `--emit-ts` mode once reuse is in place.

View File

@ -3,7 +3,8 @@
`mcporter emit-ts` turns a configured MCP server into TypeScript artifacts so
agents, tests, and tooling can consume the server through strongly typed APIs.
It reuses the same `buildToolDoc()` data that powers `mcporter list`, so doc
comments, parameter hints, and signatures stay perfectly in sync.
comments, parameter hints, and signatures stay perfectly in sync. For a broader
overview of every CLI command, see `docs/cli-reference.md`.
```
mcporter emit-ts <server> --out linear-client.ts \

22
docs/known-issues.md Normal file
View File

@ -0,0 +1,22 @@
# Known Issues
This file tracks limitations that users regularly run into. Most of these require upstream cooperation or larger refactors—feel free to reference this when triaging bugs.
## Hosted OAuth servers (Supabase, GitHub MCP, etc.)
- Supabases hosted MCP server rejects the standard `mcp:tools` scope and only accepts Supabase-specific scopes (`projects:read`, `database:write`, ...). Because they do not expose OAuth discovery metadata or scope negotiation, mcporter cannot auto-register or complete the flow. Workarounds:
- Use Supabases supported clients (Cursor, Windsurf).
- Self-host their MCP server and configure PAT headers / custom OAuth.
- Ask Supabase to accept the MCP scope or publish their scope list.
- GitHubs MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilots backend expects pre-registered client credentials. Until GitHub publishes a dynamic-registration API (or client secrets), mcporter cannot interact with their hosted server.
## Output schemas missing/buggy on many servers
- The MCP spec allows servers to omit `outputSchema`. In practice, many hosted MCPs return empty or inconsistent schemas, so features that rely on return types (TypeScript signatures, generated CLIs, `createServerProxy` return helpers) may degrade to `unknown`.
- Workarounds: inspect the servers README / manual docs for output details, or wrap the tool via `createServerProxy` and handle the raw envelope manually.
- Potential improvement: allow user-provided schema overrides (e.g., `mcporter config patch`, CLI flag to load schema JSON) so we can fill gaps on a per-tool basis.
## Next Steps
- Implement true scope negotiation (read discovery metadata, allow `--oauth-scope`).
- Keep lobbying providers for spec-compliant OAuth behavior.
- Consider adding schema override hooks or auto-caching schema snapshots per tool.
If you run into other recurring pain points, append them here so we can prioritize fixes.

59
docs/refactor.md Normal file
View File

@ -0,0 +1,59 @@
# Next-Step Refactor Checklist
This doc tracks remaining reuse/refactor work now that the original plan is done.
Each section lists the goal, why it matters, and the concrete steps/tests needed.
## 1. Shared Tool Schema Cache *(Completed)*
- **Problem**: `generate-cli` and `emit-ts` both fetch & serialize tool schemas
independently (and `mcporter list` re-parses them too).
- **What we did**:
1. Added `src/cli/tool-cache.ts` with `loadToolMetadata()` caching tool metadata per runtime/server/options.
2. Switched `mcporter list` (single-server path) and `emit-ts` to consume the helper, so both reuse `ToolMetadata`.
3. Added `tests/tool-cache.test.ts` + updated emit-ts tests to ensure the helper is covered.
- **Next**: Consider integrating the cache into `generate-cli` if we ever reuse runtime instances there.
## 2. Unified Flag Parsing for Generator-style Commands *(Completed)*
- **Problem**: `generate-cli`, `regenerate-cli`, and `emit-ts` each
reimplemented `--runtime`, `--timeout`, and `--include-optional` handling.
- **What we did**:
1. Added `extractGeneratorFlags()` in `src/cli/generate/flag-parser.ts` to
normalize shared flags while mutating `args` in place.
2. Updated all three commands to call the helper before parsing
command-specific options.
3. Added `tests/generator-flag-parser.test.ts` to cover runtime/timeout and
optional flags.
## 3. Test Fixture Reuse *(Completed)*
- **Problem**: Emit-ts/tool-cache/unit tests each defined their own tool/definition
fixtures, leading to divergence.
- **What we did**:
1. Added `tests/fixtures/tool-fixtures.ts` (shared definition + tools).
2. Updated `tests/emit-ts.test.ts` and `tests/tool-cache.test.ts` to import
the shared fixtures (and reuse them via `buildToolMetadata`).
3. Ensured the fixture covers required+optional parameters so both suites hit
the same edge cases.
## 4. CallResult Helper Extraction *(Completed)*
- **Problem**: `call-command.ts` and the emit-ts client template both wrapped
results with `createCallResult`, but there was no shared helper.
- **What we did**:
1. Added `wrapCallResult()` to `result-utils.ts` and exported it from the
package entry.
2. Updated `call-command.ts` and the emit-ts template to reuse the helper so
they stay in sync.
3. Adjusted emit-ts tests to assert the helper is referenced.
## 5. CLI Docs Consolidation *(Completed)*
- **Problem**: CLI usage guidance was scattered across README, `docs/spec.md`,
and various feature docs.
- **What we did**:
1. Added `docs/cli-reference.md` summarizing `list`, `call`, `generate-cli`,
and `emit-ts` flags/modes in one place.
2. Pointed emit-ts users to that doc so we can converge other references over
time (no README/spec churn yet to avoid clobbering parallel work).
- **Next**: Once the other doc changes land, update README/spec to link to the
reference and drop redundant sections.
---
Tracking the above here keeps future agents aligned. Update this checklist as
items ship (mark sections “Completed” when done, or delete the doc once empty).

View File

@ -5,6 +5,7 @@ import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServe
import { CliUsageError } from './cli/errors.js';
import { inferCommandRouting } from './cli/command-inference.js';
import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js';
import { extractGeneratorFlags } from './cli/generate/flag-parser.js';
import { handleEmitTs } from './cli/emit-ts-command.js';
import { handleList } from './cli/list-command.js';
import { formatSourceSuffix } from './cli/list-format.js';
@ -183,6 +184,7 @@ interface GenerateFlags {
// parseGenerateFlags extracts generate-cli specific flags from argv.
function parseGenerateFlags(args: string[]): GenerateFlags {
const common = extractGeneratorFlags(args);
let server: string | undefined;
let name: string | undefined;
let command: string | undefined;
@ -190,8 +192,8 @@ function parseGenerateFlags(args: string[]): GenerateFlags {
let output: string | undefined;
let bundle: boolean | string | undefined;
let compile: boolean | string | undefined;
let runtime: 'node' | 'bun' | undefined;
let timeout = 30_000;
let runtime: 'node' | 'bun' | undefined = common.runtime;
let timeout = common.timeout ?? 30_000;
let minify = false;
let index = 0;
@ -226,24 +228,6 @@ function parseGenerateFlags(args: string[]): GenerateFlags {
args.splice(index, 2);
continue;
}
if (token === '--runtime') {
const value = expectValue(token, args[index + 1]);
if (value !== 'node' && value !== 'bun') {
throw new Error("--runtime must be 'node' or 'bun'.");
}
runtime = value;
args.splice(index, 2);
continue;
}
if (token === '--timeout') {
const value = Number.parseInt(expectValue(token, args[index + 1]), 10);
if (!Number.isFinite(value) || value <= 0) {
throw new Error('--timeout must be a positive integer.');
}
timeout = value;
args.splice(index, 2);
continue;
}
if (token === '--bundle') {
const next = args[index + 1];
if (!next || next.startsWith('--')) {
@ -396,6 +380,13 @@ interface RegenerateParseResult {
function parseRegenerateFlags(args: string[]): RegenerateParseResult {
const overrides: RegenerateOverrides = {};
let dryRun = false;
const common = extractGeneratorFlags(args);
if (common.runtime) {
overrides.runtime = common.runtime;
}
if (common.timeout) {
overrides.timeoutMs = common.timeout;
}
let index = 0;
while (index < args.length) {
const token = args[index];
@ -418,24 +409,6 @@ function parseRegenerateFlags(args: string[]): RegenerateParseResult {
args.splice(index, 2);
continue;
}
if (token === '--runtime') {
const value = expectValue(token, args[index + 1]);
if (value !== 'node' && value !== 'bun') {
throw new Error("--runtime must be 'node' or 'bun'.");
}
overrides.runtime = value;
args.splice(index, 2);
continue;
}
if (token === '--timeout') {
const value = Number.parseInt(expectValue(token, args[index + 1]), 10);
if (!Number.isFinite(value) || value <= 0) {
throw new Error('--timeout must be a positive integer.');
}
overrides.timeoutMs = value;
args.splice(index, 2);
continue;
}
if (token === '--minify') {
overrides.minify = true;
args.splice(index, 1);

View File

@ -1,5 +1,5 @@
import type { ServerToolInfo } from '../runtime.js';
import { createCallResult } from '../result-utils.js';
import { wrapCallResult } from '../result-utils.js';
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './adhoc-server.js';
import { parseCallExpressionFragment } from './call-expression-parser.js';
import { CliUsageError } from './errors.js';
@ -261,7 +261,7 @@ export async function handleCall(
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
const { result } = await invokeWithAutoCorrection(runtime, server, tool, hydratedArgs, timeoutMs);
const wrapped = createCallResult(result);
const { callResult: wrapped } = wrapCallResult(result);
printCallOutput(wrapped, result, parsed.output);
tailLogIfRequested(result, parsed.tailLog);
dumpActiveHandles('after call (formatted result)');

View File

@ -1,10 +1,12 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ServerDefinition } from '../config.js';
import type { Runtime, ServerToolInfo } from '../runtime.js';
import type { Runtime } from '../runtime.js';
import { buildToolDoc } from './list-detail-helpers.js';
import { renderClientModule, renderTypesModule, type EmitMetadata, type ToolDocEntry } from './emit-ts-templates.js';
import { extractOptions, toProxyMethodName } from './generate/tools.js';
import type { ToolMetadata } from './generate/tools.js';
import { extractGeneratorFlags } from './generate/flag-parser.js';
import { loadToolMetadata } from './tool-cache.js';
import { readPackageMetadata } from './generate/template.js';
interface EmitTsFlags {
@ -24,14 +26,14 @@ interface ParsedEmitTsOptions extends Required<Omit<EmitTsFlags, 'server' | 'out
export async function handleEmitTs(runtime: Runtime, args: string[]): Promise<void> {
const options = parseEmitTsArgs(args);
const definition = getServerDefinition(runtime, options.server);
const tools = await runtime.listTools(options.server, { includeSchema: true, autoAuthorize: false });
const metadataEntries = await loadToolMetadata(runtime, options.server, { includeSchema: true, autoAuthorize: false });
const generator = await readPackageMetadata();
const metadata: EmitMetadata = {
server: definition,
generatorLabel: `${generator.name}@${generator.version}`,
generatedAt: new Date(),
};
const docEntries = buildDocEntries(options.server, tools, options.includeOptional);
const docEntries = buildDocEntries(options.server, metadataEntries, options.includeOptional);
const interfaceName = buildInterfaceName(options.server);
if (options.mode === 'types') {
@ -60,6 +62,10 @@ function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
mode: 'types',
includeOptional: false,
};
const common = extractGeneratorFlags(args, { allowIncludeOptional: true });
if (common.includeOptional) {
flags.includeOptional = true;
}
let index = 0;
while (index < args.length) {
const token = args[index];
@ -94,11 +100,6 @@ function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
args.splice(index, 2);
continue;
}
if (token === '--include-optional' || token === '--all-parameters') {
flags.includeOptional = true;
args.splice(index, 1);
continue;
}
if (token.startsWith('--')) {
throw new Error(`Unknown flag '${token}' for emit-ts.`);
}
@ -139,21 +140,21 @@ function getServerDefinition(runtime: Runtime, name: string): ServerDefinition {
}
}
function buildDocEntries(serverName: string, tools: ServerToolInfo[], includeOptional: boolean): ToolDocEntry[] {
return tools.map((tool) => {
function buildDocEntries(serverName: string, metadataEntries: ToolMetadata[], includeOptional: boolean): ToolDocEntry[] {
return metadataEntries.map((entry) => {
const doc = buildToolDoc({
serverName,
toolName: tool.name,
description: tool.description,
outputSchema: tool.outputSchema,
options: extractOptions(tool),
toolName: entry.tool.name,
description: entry.tool.description,
outputSchema: entry.tool.outputSchema,
options: entry.options,
requiredOnly: !includeOptional,
colorize: false,
defaultReturnType: 'CallResult',
});
return {
toolName: tool.name,
methodName: toProxyMethodName(tool.name),
toolName: entry.tool.name,
methodName: entry.methodName,
doc,
};
});

View File

@ -52,7 +52,7 @@ export function renderTypesModule(input: EmitTypesTemplateInput): string {
export function renderClientModule(input: EmitClientTemplateInput): string {
const lines: string[] = [];
lines.push(...renderHeader(input.metadata));
lines.push("import { createCallResult, createRuntime, createServerProxy } from 'mcporter';");
lines.push("import { createRuntime, createServerProxy, wrapCallResult } from 'mcporter';");
lines.push(`import type { ${input.interfaceName} } from '${input.typesImportPath}';`);
lines.push('');
lines.push('type RuntimeInstance = Awaited<ReturnType<typeof createRuntime>>;');
@ -82,7 +82,7 @@ export function renderClientModule(input: EmitClientTemplateInput): string {
` const tool = proxy.${entry.methodName} as (args: Parameters<${input.interfaceName}['${methodName}']>[0]) => Promise<unknown>;`
);
lines.push(' const raw = await tool(params);');
lines.push(' return createCallResult(raw);');
lines.push(' return wrapCallResult(raw).callResult;');
lines.push(' },');
lines.push('');
});

View File

@ -0,0 +1,50 @@
export interface GeneratorCommonFlags {
runtime?: 'node' | 'bun';
timeout?: number;
includeOptional?: boolean;
}
interface ExtractOptions {
allowIncludeOptional?: boolean;
}
export function extractGeneratorFlags(args: string[], options: ExtractOptions = {}): GeneratorCommonFlags {
const result: GeneratorCommonFlags = {};
let index = 0;
while (index < args.length) {
const token = args[index];
if (!token) {
index += 1;
continue;
}
if (token === '--runtime') {
const value = args[index + 1];
if (value !== 'node' && value !== 'bun') {
throw new Error("--runtime must be 'node' or 'bun'.");
}
result.runtime = value;
args.splice(index, 2);
continue;
}
if (token === '--timeout') {
const raw = args[index + 1];
if (!raw) {
throw new Error("Flag '--timeout' requires a value.");
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error('--timeout must be a positive integer.');
}
result.timeout = parsed;
args.splice(index, 2);
continue;
}
if (options.allowIncludeOptional && (token === '--include-optional' || token === '--all-parameters')) {
result.includeOptional = true;
args.splice(index, 1);
continue;
}
index += 1;
}
return result;
}

View File

@ -4,12 +4,10 @@ import type { ServerToolInfo } from '../runtime.js';
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './adhoc-server.js';
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
import type { GeneratedOption } from './generate/tools.js';
import { extractOptions } from './generate/tools.js';
import type { ToolMetadata } from './generate/tools.js';
import { chooseClosestIdentifier } from './identifier-helpers.js';
import {
buildToolDoc,
formatExampleBlock,
} from './list-detail-helpers.js';
import { buildToolDoc, formatExampleBlock } from './list-detail-helpers.js';
import { loadToolMetadata } from './tool-cache.js';
import type { ListSummaryResult, StatusCategory } from './list-format.js';
import { classifyListError, formatSourceSuffix, renderServerListRow } from './list-format.js';
import { boldText, cyanText, dimText, extraDimText, supportsSpinner, yellowText } from './terminal.js';
@ -196,12 +194,15 @@ export async function handleList(
const startedAt = Date.now();
try {
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
const tools = await withTimeout(runtime.listTools(target, { includeSchema: true }), timeoutMs);
const metadataEntries = await withTimeout(
loadToolMetadata(runtime, target, { includeSchema: true }),
timeoutMs
);
const durationMs = Date.now() - startedAt;
const summaryLine = printSingleServerHeader(definition, tools.length, durationMs, transportSummary, sourcePath, {
const summaryLine = printSingleServerHeader(definition, metadataEntries.length, durationMs, transportSummary, sourcePath, {
printSummaryNow: false,
});
if (tools.length === 0) {
if (metadataEntries.length === 0) {
console.log(' Tools: <none>');
console.log(summaryLine);
console.log('');
@ -209,8 +210,8 @@ export async function handleList(
}
const examples: string[] = [];
let optionalOmitted = false;
for (const tool of tools) {
const detail = printToolDetail(target, tool, Boolean(flags.schema), flags.requiredOnly);
for (const entry of metadataEntries) {
const detail = printToolDetail(target, entry, Boolean(flags.schema), flags.requiredOnly);
examples.push(...detail.examples);
optionalOmitted ||= detail.optionalOmitted;
}
@ -293,19 +294,13 @@ function printSingleServerHeader(
return summaryLine;
}
function printToolDetail(
serverName: string,
tool: { name: string; description?: string; inputSchema?: unknown; outputSchema?: unknown },
includeSchema: boolean,
requiredOnly: boolean
): ToolDetailResult {
const options = extractOptions(tool as ServerToolInfo);
function printToolDetail(serverName: string, metadata: ToolMetadata, includeSchema: boolean, requiredOnly: boolean): ToolDetailResult {
const doc = buildToolDoc({
serverName,
toolName: tool.name,
description: tool.description,
outputSchema: tool.outputSchema,
options,
toolName: metadata.tool.name,
description: metadata.tool.description,
outputSchema: metadata.tool.outputSchema,
options: metadata.options,
requiredOnly,
colorize: true,
});
@ -318,9 +313,9 @@ function printToolDetail(
if (doc.optionalSummary && requiredOnly) {
console.log(` ${doc.optionalSummary}`);
}
if (includeSchema && tool.inputSchema) {
if (includeSchema && metadata.tool.inputSchema) {
// Schemas can be large — indenting keeps multi-line JSON legible without disrupting surrounding output.
console.log(indent(JSON.stringify(tool.inputSchema, null, 2), ' '));
console.log(indent(JSON.stringify(metadata.tool.inputSchema, null, 2), ' '));
}
console.log('');
return {

34
src/cli/server-lookup.ts Normal file
View File

@ -0,0 +1,34 @@
import type { ServerDefinition } from '../config.js';
export function findServerByHttpUrl(
definitions: readonly ServerDefinition[],
urlString: string
): string | undefined {
const normalizedTarget = normalizeUrl(urlString);
if (!normalizedTarget) {
return undefined;
}
for (const definition of definitions) {
if (definition.command.kind !== 'http') {
continue;
}
const normalizedDefinitionUrl = normalizeUrl(definition.command.url);
if (!normalizedDefinitionUrl) {
continue;
}
if (normalizedDefinitionUrl === normalizedTarget) {
return definition.name;
}
}
return undefined;
}
function normalizeUrl(value: string | URL): string | undefined {
try {
const url = value instanceof URL ? value : new URL(value);
// URL#href always ends with a trailing slash for bare origins; keep it for consistency
return url.href.replace(/\/$/, '/');
} catch {
return undefined;
}
}

43
src/cli/tool-cache.ts Normal file
View File

@ -0,0 +1,43 @@
import type { Runtime } from '../runtime.js';
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
interface LoadToolMetadataOptions {
includeSchema?: boolean;
autoAuthorize?: boolean;
}
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
function cacheKey(serverName: string, options: LoadToolMetadataOptions): string {
const includeSchema = options.includeSchema !== false;
const autoAuthorize = options.autoAuthorize !== false;
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}`;
}
export async function loadToolMetadata(
runtime: Runtime,
serverName: string,
options: LoadToolMetadataOptions = {}
): Promise<ToolMetadata[]> {
const key = cacheKey(serverName, options);
let cache = runtimeCache.get(runtime);
if (!cache) {
cache = new Map();
runtimeCache.set(runtime, cache);
}
const existing = cache.get(key);
if (existing) {
return existing;
}
const includeSchema = options.includeSchema !== false;
const autoAuthorize = options.autoAuthorize !== false;
const promise = runtime
.listTools(serverName, { includeSchema, autoAuthorize })
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
.catch((error) => {
cache?.delete(key);
throw error;
});
cache.set(key, promise);
return promise;
}

View File

@ -1,7 +1,7 @@
export type { CommandSpec, ServerDefinition } from './config.js';
export { loadServerDefinitions } from './config.js';
export type { CallResult } from './result-utils.js';
export { createCallResult } from './result-utils.js';
export { createCallResult, wrapCallResult } from './result-utils.js';
export type {
CallOptions,
ListToolsOptions,

View File

@ -202,3 +202,7 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
},
};
}
export function wrapCallResult<T = unknown>(raw: T): { raw: T; callResult: CallResult<T> } {
return { raw, callResult: createCallResult(raw) };
}

View File

@ -289,7 +289,7 @@ describe('CLI list classification', () => {
expect(
lines.some((line) => line.includes('Optional parameters hidden; run with --all-parameters to view all fields'))
).toBe(false);
expect(listToolsSpy).toHaveBeenCalledWith('calculator', { includeSchema: true });
expect(listToolsSpy).toHaveBeenCalledWith('calculator', expect.objectContaining({ includeSchema: true }));
logSpy.mockRestore();
});
@ -316,7 +316,7 @@ describe('CLI list classification', () => {
expect(
lines.some((line) => line.includes('Optional parameters hidden; run with --all-parameters to view all fields'))
).toBe(true);
expect(listToolsSpy).toHaveBeenCalledWith('linear', { includeSchema: true });
expect(listToolsSpy).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
logSpy.mockRestore();
});
@ -416,7 +416,7 @@ describe('CLI list classification', () => {
expect(lines.some((line) => line.includes('limit?: number'))).toBe(true);
expect(lines.some((line) => line.includes('orderBy?: "createdAt" | "updatedAt"'))).toBe(true);
expect(lines.some((line) => line.includes('includeArchived?: boolean'))).toBe(true);
expect(listToolsSpy).toHaveBeenCalledWith('linear', { includeSchema: true });
expect(listToolsSpy).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
logSpy.mockRestore();
});
@ -528,7 +528,7 @@ describe('CLI list classification', () => {
expect(registerDefinition).toHaveBeenCalled();
expect(definitions.get('mcp-example-com-mcp')).toBeDefined();
expect(listTools).toHaveBeenCalledWith('mcp-example-com-mcp', { includeSchema: true });
expect(listTools).toHaveBeenCalledWith('mcp-example-com-mcp', expect.objectContaining({ includeSchema: true }));
logSpy.mockRestore();
});
@ -554,7 +554,7 @@ describe('CLI list classification', () => {
await handleList(runtime, ['linera']);
expect(getDefinition).toHaveBeenCalledTimes(2);
expect(listTools).toHaveBeenCalledWith('linear', { includeSchema: true });
expect(listTools).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
const messages = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
expect(messages.some((line) => line.includes('Auto-corrected server name to linear'))).toBe(true);

View File

@ -2,39 +2,19 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import type { Runtime, ServerToolInfo } from '../src/runtime.js';
import type { Runtime } from '../src/runtime.js';
import { handleEmitTs, __test as emitTsTestInternals } from '../src/cli/emit-ts-command.js';
import { renderClientModule, renderTypesModule } from '../src/cli/emit-ts-templates.js';
const sampleDefinition: ServerDefinition = {
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: 'https://example.com/mcp' },
transport: 'stdio',
};
const sampleTool: ServerToolInfo = {
name: 'list_comments',
description: 'List comments for an issue',
inputSchema: {
type: 'object',
properties: {
issueId: { type: 'string', description: 'Issue identifier' },
limit: { type: 'number', description: 'Limit results', default: 10 },
},
required: ['issueId'],
},
outputSchema: { title: 'CommentList' },
};
import { buildToolMetadata } from '../src/cli/generate/tools.js';
import { integrationDefinition, listCommentsTool } from './fixtures/tool-fixtures.js';
function createRuntimeStub(): Runtime {
return {
listServers: () => ['integration'],
getDefinitions: () => [sampleDefinition],
getDefinition: () => sampleDefinition,
getDefinitions: () => [integrationDefinition],
getDefinition: () => integrationDefinition,
registerDefinition: () => {},
listTools: async () => [sampleTool],
listTools: async () => [listCommentsTool],
callTool: async () => ({}),
listResources: async () => ({}),
connect: async () => {
@ -46,9 +26,9 @@ function createRuntimeStub(): Runtime {
describe('emit-ts templates', () => {
it('renders type declarations with CallResult returns', () => {
const docs = emitTsTestInternals.buildDocEntries('integration', [sampleTool], false);
const docs = emitTsTestInternals.buildDocEntries('integration', [buildToolMetadata(listCommentsTool)], false);
const metadata = {
server: sampleDefinition,
server: integrationDefinition,
generatorLabel: 'mcporter@test',
generatedAt: new Date('2025-11-07T00:00:00Z'),
};
@ -59,9 +39,9 @@ describe('emit-ts templates', () => {
});
it('renders client module that wraps proxy calls', () => {
const docs = emitTsTestInternals.buildDocEntries('integration', [sampleTool], true);
const docs = emitTsTestInternals.buildDocEntries('integration', [buildToolMetadata(listCommentsTool)], true);
const metadata = {
server: sampleDefinition,
server: integrationDefinition,
generatorLabel: 'mcporter@test',
generatedAt: new Date('2025-11-07T00:00:00Z'),
};
@ -72,7 +52,7 @@ describe('emit-ts templates', () => {
typesImportPath: './integration-client',
});
expect(source).toContain('createIntegrationClient');
expect(source).toContain('createCallResult');
expect(source).toContain('wrapCallResult');
expect(source).toContain('proxy.listComments');
});
});

36
tests/fixtures/tool-fixtures.ts vendored Normal file
View File

@ -0,0 +1,36 @@
import type { ServerDefinition } from '../../src/config.js';
import type { ServerToolInfo } from '../../src/runtime.js';
export const integrationDefinition: ServerDefinition = {
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: 'https://example.com/mcp' },
transport: 'stdio',
};
export const listCommentsTool: ServerToolInfo = {
name: 'list_comments',
description: 'List comments for an issue',
inputSchema: {
type: 'object',
properties: {
issueId: { type: 'string', description: 'Issue identifier' },
limit: { type: 'number', description: 'Limit results', default: 10 },
},
required: ['issueId'],
},
outputSchema: { title: 'CommentList' },
};
export const demoTool: ServerToolInfo = {
name: 'demo_tool',
description: 'Demo tool',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string', description: 'Value' },
},
required: ['value'],
},
outputSchema: { title: 'DemoResult' },
};

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { extractGeneratorFlags } from '../src/cli/generate/flag-parser.js';
describe('extractGeneratorFlags', () => {
it('parses runtime and timeout flags', () => {
const args = ['--runtime', 'bun', '--timeout', '4500', 'extra'];
const common = extractGeneratorFlags(args);
expect(common.runtime).toBe('bun');
expect(common.timeout).toBe(4500);
expect(args).toEqual(['extra']);
});
it('handles include optional aliases when enabled', () => {
const args = ['--include-optional', '--all-parameters'];
const common = extractGeneratorFlags(args, { allowIncludeOptional: true });
expect(common.includeOptional).toBe(true);
expect(args).toEqual([]);
});
});

42
tests/tool-cache.test.ts Normal file
View File

@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { Runtime } from '../src/runtime.js';
import { loadToolMetadata } from '../src/cli/tool-cache.js';
import { demoTool } from './fixtures/tool-fixtures.js';
function createRuntimeStub(listToolsImpl: Runtime['listTools']): Runtime {
return {
listServers: () => [],
getDefinitions: () => [],
getDefinition: () => {
throw new Error('not implemented');
},
registerDefinition: () => {},
listTools: listToolsImpl,
callTool: async () => ({}),
listResources: async () => ({}),
connect: async () => {
throw new Error('not implemented');
},
close: async () => {},
} as unknown as Runtime;
}
describe('loadToolMetadata', () => {
it('caches repeated calls per runtime/server/options', async () => {
const listTools = vi.fn(async () => [demoTool]);
const runtime = createRuntimeStub(listTools);
const first = await loadToolMetadata(runtime, 'integration', { includeSchema: true });
const second = await loadToolMetadata(runtime, 'integration', { includeSchema: true });
expect(listTools).toHaveBeenCalledTimes(1);
expect(first[0]?.tool.name).toBe('demo_tool');
expect(second).toBe(first);
});
it('differentiates cache entries by includeSchema flag', async () => {
const listTools = vi.fn(async () => [demoTool]);
const runtime = createRuntimeStub(listTools);
await loadToolMetadata(runtime, 'integration', { includeSchema: true });
await loadToolMetadata(runtime, 'integration', { includeSchema: false });
expect(listTools).toHaveBeenCalledTimes(2);
});
});