diff --git a/docs/call-syntax.md b/docs/call-syntax.md index 6cb3677..979d46d 100644 --- a/docs/call-syntax.md +++ b/docs/call-syntax.md @@ -11,25 +11,28 @@ Both forms share the same validation pipeline, so required parameters, enums, an ## Reading the CLI Signatures -`mcporter list ` now prints each tool like a mini TypeScript snippet: +`mcporter list ` 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 parameter’s schema description is shown as a dimmed `//` comment to match the CLI styling. -- Return values are summarised with `->` lines derived from the tool’s output schema; when no schema is available we emit `-> result: unknown` as a fallback. -- After the tool list you’ll 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 --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 tool’s output schema, so you’ll 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 diff --git a/src/cli/list-command.ts b/src/cli/list-command.ts index 2ae2860..196c8ff 100644 --- a/src/cli/list-command.ts +++ b/src/cli/list-command.ts @@ -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; - 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; - 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; - 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) : 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); } function inferSchemaDisplayType(descriptor: Record): 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 { if (!type && descriptor.items && typeof descriptor.items === 'object') { return `${inferSchemaDisplayType(descriptor.items as Record)}[]`; } + if (type === 'array' && descriptor.items && typeof descriptor.items === 'object') { + return `${inferSchemaDisplayType(descriptor.items as Record)}[]`; + } 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)}[]`; - } return type ?? 'unknown'; } diff --git a/tests/cli-list.test.ts b/tests/cli-list.test.ts index ed5f679..66d58c1 100644 --- a/tests/cli-list.test.ts +++ b/tests/cli-list.test.ts @@ -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>; + + 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>; 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(); });