Improve list command signature formatting

This commit is contained in:
Peter Steinberger 2025-11-06 22:08:26 +00:00
parent a3fdcba097
commit be79738b16
3 changed files with 191 additions and 189 deletions

View File

@ -11,25 +11,28 @@ Both forms share the same validation pipeline, so required parameters, enums, an
## Reading the CLI Signatures
`mcporter list <server>` now prints each tool like a mini TypeScript snippet:
`mcporter list <server>` prints each tool as a compact TypeScript declaration:
```ts
// Create a comment on a specific Linear issue
create_comment({
issueId: string // The issue ID
body: string // The content of the comment as Markdown
parentId?: string // A parent comment ID to reply to
})
-> result: array // List of calculation results (falls back to `unknown` when unspecified)
-> total: number // Total results returned
/**
* Create a comment on a specific Linear issue.
* @param issueId The issue ID
* @param body The content of the comment as Markdown
* @param parentId? A parent comment ID to reply to
*/
function create_comment(issueId: string, body: string, parentId?: string): Comment;
// optional (3): notifySubscribers, labelIds, mentionIds, ...
```
- Required parameters appear without `?`, optional parameters use `?`.
- Literal unions (enums) render as `"json" | "markdown"`.
- Known formats (e.g. ISO 8601) surface inline: `dueDate?: string /* ISO 8601 */`.
- Each parameters schema description is shown as a dimmed `//` comment to match the CLI styling.
- Return values are summarised with `->` lines derived from the tools output schema; when no schema is available we emit `-> result: unknown` as a fallback.
- After the tool list youll see an `Examples:` block with a few ready-to-run calls; the legacy flag form is still accepted but no longer printed for every tool.
Key details:
- Doc blocks use `@param` lines so every parameter description (even optional ones) stays in view.
- Required parameters appear without `?`; optional parameters use `?` and inherit enum literals (e.g. `"json" | "markdown"`).
- Known format hints are appended inline: `dueDate?: string /* ISO 8601 */` (we suppress the hint when the description already calls it out).
- When a tool exposes more than two optional parameters (or has ≥4 required parameters), the default output hides the extras and replaces them with an inline summary like `// optional (8): limit, before, after, orderBy, projectId, ...`.
- Run `mcporter list <server> --include-optional` whenever you want the full signature; the footer repeats `Optional parameters hidden; run with --include-optional to view all fields.` any time truncation occurs.
- Return types come from each tools output schema, so youll see concrete names when providers include `title` metadata (e.g. `DocumentConnection`). When no schema is advertised we omit the `: Type` suffix entirely instead of showing `unknown`.
- Each server concludes with a short `Examples:` block that mirrors the preferred function-call syntax.
## Function-Call Syntax Details

View File

@ -364,164 +364,117 @@ function printToolDetail(
requiredOnly: boolean
): ToolDetailResult {
const options = extractOptions(tool as ServerToolInfo);
const visibleOptions = requiredOnly ? options.filter((entry) => entry.required) : options;
const lines = formatToolSignatureBlock(tool.name, tool.description ?? '', visibleOptions, options, requiredOnly);
for (const line of lines) {
console.log(` ${line}`);
const { displayOptions, hiddenOptions } = selectDisplayOptions(options, requiredOnly);
const docLines = buildDocComment(tool.description, options);
if (docLines) {
for (const line of docLines) {
console.log(` ${line}`);
}
}
console.log(` ${formatFunctionSignature(tool.name, displayOptions, tool.outputSchema)}`);
if (hiddenOptions.length > 0 && requiredOnly) {
console.log(` ${formatOptionalSummary(hiddenOptions)}`);
}
if (includeSchema && 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), ' '));
}
const returnLines = formatReturnLines(tool.outputSchema);
if (returnLines && returnLines.length > 0) {
for (const line of returnLines) {
console.log(` ${line}`);
}
}
console.log('');
return {
example: formatCallExpressionExample(serverName, tool.name, visibleOptions.length > 0 ? visibleOptions : options),
optionalOmitted: requiredOnly && options.length > visibleOptions.length,
example: formatCallExpressionExample(serverName, tool.name, displayOptions.length > 0 ? displayOptions : options),
optionalOmitted: hiddenOptions.length > 0,
};
}
function formatToolSignatureBlock(
name: string,
description: string,
visibleOptions: GeneratedOption[],
allOptions: GeneratedOption[],
function selectDisplayOptions(
options: GeneratedOption[],
requiredOnly: boolean
): string[] {
const lines: string[] = [];
if (description) {
lines.push(extraDimText(`// ${description}`));
): { displayOptions: GeneratedOption[]; hiddenOptions: GeneratedOption[] } {
if (!requiredOnly) {
return { displayOptions: options, hiddenOptions: [] };
}
const omittedOptions = requiredOnly ? allOptions.filter((entry) => !entry.required) : [];
const optionalNote = formatOptionalNote(omittedOptions);
const inlineEligible = isInlineFriendly(visibleOptions, optionalNote);
if (inlineEligible) {
const signature = buildInlineSignature(name, visibleOptions);
lines.push(optionalNote ? `${signature} ${optionalNote}` : signature);
return lines;
const requiredCount = options.filter((option) => option.required).length;
const optionalCount = options.length - requiredCount;
const includeOptional = optionalCount > 0 && optionalCount <= 2 && requiredCount < 4;
const displayOptions: GeneratedOption[] = [];
const hiddenOptions: GeneratedOption[] = [];
for (const option of options) {
if (option.required || includeOptional) {
displayOptions.push(option);
} else {
hiddenOptions.push(option);
}
}
if (visibleOptions.length === 0) {
const signature = requiredOnly && allOptions.length > 0 ? `${cyanText(name)}({})` : `${cyanText(name)}()`;
lines.push(optionalNote ? `${signature} ${optionalNote}` : signature);
return lines;
}
lines.push(`${cyanText(name)}({`);
for (const option of visibleOptions) {
lines.push(` ${formatParameterSignature(option)}`);
}
const closing = optionalNote ? `}) ${optionalNote}` : '})';
lines.push(closing);
return lines;
return { displayOptions, hiddenOptions };
}
function isInlineFriendly(options: GeneratedOption[], optionalNote: string | undefined): boolean {
if (options.length === 0) {
return true;
}
if (options.length > 2) {
return false;
}
return options.every((option) => {
const commentLength = option.description?.length ?? 0;
return commentsFitsInline(commentLength) && !option.enumValues && option.type !== 'array';
});
}
function commentsFitsInline(length: number, max = 60): boolean {
return length <= max;
}
function buildInlineSignature(name: string, options: GeneratedOption[]): string {
if (options.length === 0) {
return `${cyanText(name)}()`;
}
const parts = options.map((option) => {
const typeAnnotation = formatTypeAnnotation(option);
const optionalSuffix = option.required ? '' : '?';
const commentSuffix = option.description ? ` ${extraDimText(`// ${option.description}`)}` : '';
return `${option.property}${optionalSuffix}: ${typeAnnotation}${commentSuffix}`;
});
if (options.length === 1) {
return `${cyanText(name)}(${parts[0]})`;
}
return `${cyanText(name)}({ ${parts.join(', ')} })`;
}
function formatOptionalNote(omittedOptions: GeneratedOption[], includeAll: boolean): string | undefined {
if (omittedOptions.length === 0) {
function buildDocComment(description: string | undefined, options: GeneratedOption[]): string[] | undefined {
const descriptionLines = description?.trim().split(/\r?\n/) ?? [];
const paramDocs = options.filter((option) => option.description);
if (descriptionLines.length === 0 && paramDocs.length === 0) {
return undefined;
}
if (includeAll) {
return undefined;
const lines: string[] = ['/**'];
for (const line of descriptionLines) {
if (line.trim().length > 0) {
lines.push(` * ${line.trimEnd()}`);
}
}
const names = omittedOptions.map((option) => option.property);
const truncated = names.length > 5 ? [...names.slice(0, 5), '…'] : names;
return extraDimText(`// optional (${names.length}): ${truncated.join(', ')}`);
for (const option of paramDocs) {
const descriptionLines = option.description?.split(/\r?\n/) ?? [''];
descriptionLines.forEach((entry, index) => {
const suffix = entry.trimEnd();
if (index === 0) {
lines.push(` * @param ${option.property}${option.required ? '' : '?'} ${suffix}`);
return;
}
if (suffix.length > 0) {
lines.push(` * ${suffix}`);
}
});
}
lines.push(' */');
return lines.map((line) => extraDimText(line));
}
function formatReturnLines(schema: unknown): string[] | undefined {
function formatFunctionSignature(name: string, options: GeneratedOption[], outputSchema: unknown): string {
const paramsText = options.map(formatInlineParameter).join(', ');
const returnType = inferReturnTypeName(outputSchema);
const signature = `${cyanText(`function ${name}`)}(${paramsText})`;
return returnType ? `${signature}: ${returnType};` : `${signature};`;
}
function formatInlineParameter(option: GeneratedOption): string {
const typeAnnotation = formatTypeAnnotation(option);
const optionalSuffix = option.required ? '' : '?';
return `${option.property}${optionalSuffix}: ${typeAnnotation}`;
}
function formatOptionalSummary(hiddenOptions: GeneratedOption[]): string {
const maxNames = 5;
const names = hiddenOptions.map((option) => option.property);
if (names.length === 0) {
return '';
}
const preview = names.slice(0, maxNames).join(', ');
const suffix = names.length > maxNames ? ', ...' : '';
return extraDimText(`// optional (${names.length}): ${preview}${suffix}`);
}
function inferReturnTypeName(schema: unknown): string | undefined {
if (!schema || typeof schema !== 'object') {
return undefined;
}
const record = schema as Record<string, unknown>;
const type = typeof record.type === 'string' ? (record.type as string) : undefined;
if (type === 'object' || (!type && typeof record.properties === 'object')) {
const properties = (record.properties ?? {}) as Record<string, unknown>;
const entries = Object.entries(properties);
if (entries.length === 0) {
return ['-> result: object'];
}
const lines: string[] = [];
const limit = 5;
entries.slice(0, limit).forEach(([key, descriptor]) => {
if (!descriptor || typeof descriptor !== 'object') {
lines.push(formatReturnEntry(key, 'unknown'));
return;
}
const descRecord = descriptor as Record<string, unknown>;
const descType = inferSchemaDisplayType(descRecord);
const description = typeof descRecord.description === 'string' ? (descRecord.description as string) : undefined;
lines.push(formatReturnEntry(key, descType, description));
});
if (entries.length > limit) {
lines.push(extraDimText(`-> … ${entries.length - limit} more field(s)`));
}
return lines;
}
if (type === 'array') {
const items =
record.items && typeof record.items === 'object' ? (record.items as Record<string, unknown>) : undefined;
const itemType = items ? inferSchemaDisplayType(items) : 'unknown';
const description = items && typeof items.description === 'string' ? (items.description as string) : undefined;
return [formatReturnEntry('items[]', itemType, description)];
}
if (type) {
return [formatReturnEntry('result', type)];
}
return undefined;
}
function formatReturnEntry(name: string, type: string, description?: string): string {
const typeText = dimText(type);
const comment = description ? ` ${extraDimText(`// ${description}`)}` : '';
return `-> ${name}: ${typeText}${comment}`;
return inferSchemaDisplayType(schema as Record<string, unknown>);
}
function inferSchemaDisplayType(descriptor: Record<string, unknown>): string {
const title = typeof descriptor.title === 'string' ? descriptor.title.trim() : undefined;
if (title) {
return title;
}
const type = typeof descriptor.type === 'string' ? (descriptor.type as string) : undefined;
if (!type && typeof descriptor.properties === 'object') {
return 'object';
@ -529,15 +482,15 @@ function inferSchemaDisplayType(descriptor: Record<string, unknown>): string {
if (!type && descriptor.items && typeof descriptor.items === 'object') {
return `${inferSchemaDisplayType(descriptor.items as Record<string, unknown>)}[]`;
}
if (type === 'array' && descriptor.items && typeof descriptor.items === 'object') {
return `${inferSchemaDisplayType(descriptor.items as Record<string, unknown>)}[]`;
}
if (!type && Array.isArray(descriptor.enum)) {
const values = (descriptor.enum as unknown[]).filter((entry): entry is string => typeof entry === 'string');
if (values.length > 0) {
return values.map((entry) => JSON.stringify(entry)).join(' | ');
}
}
if (type === 'array' && descriptor.items && typeof descriptor.items === 'object') {
return `${inferSchemaDisplayType(descriptor.items as Record<string, unknown>)}[]`;
}
return type ?? 'unknown';
}

View File

@ -23,6 +23,44 @@ const stripAnsi = (value: string): string => {
return result;
};
const linearDefinition: ServerDefinition = {
name: 'linear',
description: 'Hosted Linear MCP',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
};
const buildLinearDocumentsTool = (includeSchema?: boolean) => ({
name: 'list_documents',
description: "List documents in the user's Linear workspace",
inputSchema: includeSchema
? {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query' },
limit: { type: 'number', description: 'Maximum number of documents to return' },
before: { type: 'string', description: 'Cursor to page backwards' },
after: { type: 'string', description: 'Cursor to page forwards' },
orderBy: {
type: 'string',
description: 'Sort order for the documents',
enum: ['createdAt', 'updatedAt'],
},
projectId: { type: 'string', description: 'Filter by project' },
initiativeId: { type: 'string', description: 'Filter by initiative' },
creatorId: { type: 'string', description: 'Filter by creator' },
includeArchived: { type: 'boolean', description: 'Whether to include archived documents' },
},
required: ['query'],
}
: undefined,
outputSchema: includeSchema
? {
title: 'DocumentConnection',
type: 'object',
}
: undefined,
});
describe('CLI list timeout handling', () => {
it('parses --timeout flag into list flags', async () => {
const { extractListFlags } = await cliModulePromise;
@ -212,20 +250,45 @@ describe('CLI list classification', () => {
expect(detailLine).toMatch(/1 tool/);
expect(detailLine).toMatch(/ms/);
expect(detailLine).toContain('HTTP https://example.com/mcp');
expect(lines.some((line) => line.includes('// Add two numbers'))).toBe(true);
expect(lines.some((line) => line.includes('add(a: number'))).toBe(true);
expect(lines.some((line) => line.includes('First operand'))).toBe(true);
expect(lines.some((line) => line.includes('format?:'))).toBe(false);
expect(lines.some((line) => line.includes('dueBefore?:'))).toBe(false);
expect(lines.some((line) => line.includes('// optional (2): format, dueBefore'))).toBe(true);
expect(lines.some((line) => line.includes('-> result:'))).toBe(true);
expect(lines.some((line) => line.includes('-> total:'))).toBe(true);
expect(lines.some((line) => line.includes('/**'))).toBe(true);
expect(lines.some((line) => line.includes('@param a') && line.includes('First operand'))).toBe(true);
expect(lines.some((line) => line.includes('function add('))).toBe(true);
expect(lines.some((line) => line.includes('format?: "json" | "markdown"'))).toBe(true);
expect(lines.some((line) => line.includes('dueBefore?: string'))).toBe(true);
expect(lines.some((line) => line.includes('// optional'))).toBe(false);
expect(lines.some((line) => line.includes('Examples:'))).toBe(true);
expect(lines.some((line) => line.includes('mcporter call calculator.add(a: 1)'))).toBe(true);
expect(lines.some((line) => line.includes('mcporter call calculator.add(a: 1'))).toBe(true);
expect(
lines.some((line) => line.includes('Optional parameters hidden; run with --include-optional to view all fields'))
).toBe(false);
expect(listToolsSpy).toHaveBeenCalledWith('calculator', { includeSchema: true });
logSpy.mockRestore();
});
it('summarizes hidden optional parameters and hints include flag', async () => {
const { handleList } = await cliModulePromise;
const listToolsSpy = vi.fn((_name: string, options?: { includeSchema?: boolean }) =>
Promise.resolve([buildLinearDocumentsTool(options?.includeSchema)])
);
const runtime = {
getDefinition: () => linearDefinition,
listTools: listToolsSpy,
} as unknown as Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleList(runtime, ['linear']);
const lines = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
expect(lines.some((line) => line.includes('function list_documents('))).toBe(true);
expect(lines.some((line) => line.includes('// optional (8): limit, before, after, orderBy, projectId, ...'))).toBe(
true
);
expect(
lines.some((line) => line.includes('Optional parameters hidden; run with --include-optional to view all fields'))
).toBe(true);
expect(listToolsSpy).toHaveBeenCalledWith('calculator', { includeSchema: true });
expect(listToolsSpy).toHaveBeenCalledWith('linear', { includeSchema: true });
logSpy.mockRestore();
});
@ -233,51 +296,34 @@ describe('CLI list classification', () => {
it('includes optional parameters when --include-optional is set', async () => {
const { handleList } = await cliModulePromise;
const listToolsSpy = vi.fn((_name: string, options?: { includeSchema?: boolean }) =>
Promise.resolve([
{
name: 'add',
description: 'Add two numbers',
inputSchema: options?.includeSchema
? {
type: 'object',
properties: {
a: { type: 'number', description: 'First operand' },
format: { type: 'string', enum: ['json', 'markdown'], description: 'Output serialization format' },
dueBefore: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp' },
},
required: ['a'],
}
: undefined,
},
])
Promise.resolve([buildLinearDocumentsTool(options?.includeSchema)])
);
const runtime = {
getDefinition: (name: string) => ({
name,
description: 'Test integration server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
}),
getDefinition: () => linearDefinition,
listTools: listToolsSpy,
} as unknown as Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleList(runtime, ['--include-optional', 'calculator']);
await handleList(runtime, ['--include-optional', 'linear']);
const lines = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
const headerLine = lines.find((line) => line.trim().startsWith('calculator -'));
const headerLine = lines.find((line) => line.trim().startsWith('linear -'));
expect(headerLine).toBeDefined();
const detailLine = lines[lines.indexOf(headerLine as string) + 1] ?? '';
expect(detailLine).toMatch(/1 tool/);
expect(detailLine).toMatch(/ms/);
expect(detailLine).toContain('HTTP https://example.com/mcp');
expect(lines.some((line) => line.includes('add({'))).toBe(true);
expect(lines.some((line) => line.includes('a: number') && line.includes('First operand'))).toBe(true);
expect(lines.some((line) => line.includes('format?: "json" | "markdown"'))).toBe(true);
expect(lines.some((line) => line.includes('dueBefore?: string'))).toBe(true);
expect(lines.some((line) => line.includes('mcporter call calculator.add(a: 1, format: "json")'))).toBe(true);
expect(listToolsSpy).toHaveBeenCalledWith('calculator', { includeSchema: true });
expect(lines.some((line) => line.includes('/**'))).toBe(true);
expect(lines.some((line) => line.includes('@param limit?') && line.includes('Maximum number of documents'))).toBe(
true
);
expect(lines.some((line) => line.includes('function list_documents('))).toBe(true);
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 });
logSpy.mockRestore();
});