diff --git a/.gitattributes b/.gitattributes index d0c0c4c..85916ad 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ * text eol=lf *.png binary +*.tar.gz binary +*.gz binary +*.tgz binary diff --git a/README.md b/README.md index be82bb6..c061625 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,16 @@ npx mcporter call 'linear.create_comment(issueId: "ENG-123", body: "Looks good!" ```bash npx mcporter list npx mcporter list context7 --schema +npx mcporter list context7 --brief # Show concise signatures +npx mcporter list context7 "resolve-*" --brief # Filter tools by name pattern +npx mcporter list context7 query-docs --brief # Check for a specific tool 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 ``` +- Add `--brief` to show concise TypeScript-style function signatures instead of full details. This mode supports filtering tools by name using glob patterns (e.g. `list server "amz_*"`). + - Add `--json` to emit a machine-readable summary with per-server statuses (auth/offline/http/error counts) and, for single-server runs, the full tool schema payload. - Add `--verbose` to show every config source that registered the server name (primary first), both in text and JSON list output. @@ -149,11 +154,12 @@ Helpful flags: - `--save-images ` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged). - `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings. - `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion). -- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata. +- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata. Mutually exclusive with `--brief`. - `--output json/raw` (on `mcporter call`) -- when a connection fails, MCPorter prints the usual colorized hint and also emits a structured `{ server, tool, issue }` envelope so scripts can handle auth/offline/http errors programmatically. - `--json` (on `mcporter auth`) -- emit the same structured connection envelope whenever OAuth/transport setup fails, instead of throwing an error. - `--json` (on `mcporter emit-ts`) -- print a JSON summary describing the emitted files (mode + output paths) instead of text logs—handy when generating artifacts inside scripts. - `--all-parameters` -- show every schema field when listing a server (default output shows at least five parameters plus a summary of the rest). +- `--brief` -- show concise tool signatures only; supports filtering tools by name (e.g. `mcporter list server "query_*" --brief`). Mutually exclusive with `--json`, `--schema`, `--verbose`, and `--all-parameters`. - `--http-url ` / `--stdio "command …"` -- describe an ad-hoc MCP server inline. STDIO transports now inherit your current shell environment automatically; add `--env KEY=value` only when you need to inject/override variables alongside `--cwd`, `--name`, or `--persist `. These flags now work with `mcporter auth` too, so `mcporter auth https://mcp.example.com/mcp` just works. - For OAuth-protected servers such as `vercel`, run `npx mcporter auth vercel` once to complete login. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 155200a..80d5c79 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -19,6 +19,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits - Flags: - `--all-parameters` – include every optional parameter in the signature. - `--schema` – pretty-print the JSON schema for each tool. + - `--brief` – show concise tool signatures only. Mutually exclusive with `--json`, `--schema`, etc. + - `--json` – emit a machine-readable summary instead of text. Mutually exclusive with `--brief`. - `--timeout ` – per-server timeout when enumerating all servers. ## `mcporter call ` diff --git a/package.json b/package.json index ef57152..0cf37c0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "commander": "^14.0.3", "es-toolkit": "^1.45.0", "jsonc-parser": "^3.3.1", + "minimatch": "^10.1.2", "ora": "^9.3.0", "rolldown": "1.0.0-rc.6", "zod": "^4.3.6" @@ -101,4 +102,4 @@ } ] } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01913da..6c1506a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + minimatch: + specifier: ^10.1.2 + version: 10.1.2 ora: specifier: ^9.3.0 version: 9.3.0 @@ -338,6 +341,14 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.1': + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} + engines: {node: 20 || >=22} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1200,6 +1211,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@10.1.2: + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} + engines: {node: 20 || >=22} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1722,6 +1737,12 @@ snapshots: '@iarna/toml@2.2.5': {} + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.1': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2463,6 +2484,10 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.1.2: + dependencies: + '@isaacs/brace-expansion': 5.0.1 + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 diff --git a/src/cli/list-command.ts b/src/cli/list-command.ts index 952d482..7e71449 100644 --- a/src/cli/list-command.ts +++ b/src/cli/list-command.ts @@ -8,6 +8,7 @@ import { prepareEphemeralServerTarget } from './ephemeral-target.js'; import { splitHttpToolSelector } from './http-utils.js'; import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from './identifier-helpers.js'; import { formatExampleBlock } from './list-detail-helpers.js'; +import { filterToolsByPattern } from './list-filter.js'; import type { ListSummaryResult, StatusCategory } from './list-format.js'; import { classifyListError, formatSourceSuffix, renderServerListRow } from './list-format.js'; import { @@ -16,6 +17,7 @@ import { createEmptyStatusCounts, createUnknownResult, type ListJsonServerEntry, + printBriefToolList, printSingleServerHeader, printToolDetail, summarizeStatusCounts, @@ -34,12 +36,14 @@ export function extractListFlags(args: string[]): { format: ListOutputFormat; verbose: boolean; includeSources: boolean; + brief: boolean; } { let schema = false; let timeoutMs: number | undefined; let requiredOnly = true; let verbose = false; let includeSources = false; + let brief = false; const format = consumeOutputFormat(args, { defaultFormat: 'text', allowed: ['text', 'json'], @@ -74,13 +78,39 @@ export function extractListFlags(args: string[]): { args.splice(index, 1); continue; } + if (token === '--brief') { + brief = true; + args.splice(index, 1); + continue; + } if (token === '--timeout') { timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' }); continue; } index += 1; } - return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose, includeSources }; + + // Validate mutual exclusion for --brief + if (brief) { + const conflicts: string[] = []; + if (format === 'json') { + conflicts.push('--json'); + } + if (schema) { + conflicts.push('--schema'); + } + if (verbose) { + conflicts.push('--verbose'); + } + if (!requiredOnly) { + conflicts.push('--all-parameters'); + } + if (conflicts.length > 0) { + throw new Error(`--brief cannot be used with ${conflicts.join(', ')}`); + } + } + + return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose, includeSources, brief }; } type ListOutputFormat = 'text' | 'json'; @@ -90,7 +120,24 @@ export async function handleList( args: string[] ): Promise { const flags = extractListFlags(args); - let target = args.shift(); + let target: string | undefined; + let toolPattern: string | undefined; + + // In ephemeral mode (e.g. --http-url), positional args are treated as the tool pattern. + // In normal mode, the first arg is the target (name/URL), second is the pattern. + if (flags.ephemeral) { + if (args.length > 0 && args[0] !== undefined && !args[0].startsWith('-')) { + toolPattern = args.shift(); + } + } else { + // Standard mode: [target] [pattern] + if (args.length > 0 && args[0] !== undefined && !args[0].startsWith('-')) { + target = args.shift(); + } + if (args.length > 0 && args[0] !== undefined && !args[0].startsWith('-')) { + toolPattern = args.shift(); + } + } if (target) { const split = splitHttpToolSelector(target); @@ -297,8 +344,34 @@ export async function handleList( } try { // Always request schemas so we can render CLI-style parameter hints without re-querying per tool. - const metadataEntries = await withTimeout(loadToolMetadata(runtime, target, { includeSchema: true }), timeoutMs); + let metadataEntries = await withTimeout(loadToolMetadata(runtime, target, { includeSchema: true }), timeoutMs); const durationMs = Date.now() - startedAt; + + // Apply tool pattern filter if specified + if (toolPattern) { + const filteredNames = new Set( + filterToolsByPattern( + metadataEntries.map((entry) => ({ name: entry.tool.name })), + toolPattern + ).map((f) => f.name) + ); + metadataEntries = metadataEntries.filter((e) => filteredNames.has(e.tool.name)); + } + + // Handle --brief output mode + if (flags.brief) { + if (metadataEntries.length === 0) { + if (toolPattern) { + console.log(`No tools matching pattern '${toolPattern}'.`); + } else { + console.log('No tools available.'); + } + return; + } + printBriefToolList(metadataEntries, { colorize: true }); + return; + } + const summaryLine = printSingleServerHeader( definition, metadataEntries.length, @@ -310,7 +383,11 @@ export async function handleList( } ); if (metadataEntries.length === 0) { - console.log(' Tools: '); + if (toolPattern) { + console.log(` Tools: `); + } else { + console.log(' Tools: '); + } console.log(summaryLine); console.log(''); return; @@ -354,7 +431,12 @@ export async function handleList( export function printListHelp(): void { const lines = [ - 'Usage: mcporter list [server | url] [flags]', + 'Usage: mcporter list [target] [tool-pattern] [flags]', + '', + 'Arguments:', + ' Server name from config or URL. (Optional if using ad-hoc flags)', + " Filter tools by name (supports glob: 'amz_*').", + ' If using ad-hoc flags (e.g. --http-url), the first argument is the pattern.', '', 'Targets:', ' Use a server from config/mcporter.json or editor imports.', @@ -373,6 +455,8 @@ export function printListHelp(): void { ' --yes Skip confirmation prompts when persisting.', '', 'Display flags:', + ' --brief Show tools as function signatures only.', + ' Cannot be used with --json, --schema, --verbose, --all-parameters.', ' --schema Show tool schemas when listing servers.', ' --all-parameters Include optional parameters in tool docs.', ' --json Emit a JSON summary instead of text.', @@ -383,6 +467,9 @@ export function printListHelp(): void { 'Examples:', ' mcporter list', ' mcporter list linear --schema', + ' mcporter list linear --brief', + " mcporter list linear 'add_*'", + ' mcporter list linear add_user --schema', ' mcporter list https://mcp.example.com/mcp', ' mcporter list --http-url https://localhost:3333/mcp --schema', ]; diff --git a/src/cli/list-filter.ts b/src/cli/list-filter.ts new file mode 100644 index 0000000..78e9a8e --- /dev/null +++ b/src/cli/list-filter.ts @@ -0,0 +1,18 @@ +import { minimatch } from 'minimatch'; + +/** + * Filter tools by a glob pattern matching tool names. + * @param tools Array of items with a `name` property + * @param pattern Glob pattern to match against tool names + * @returns Filtered array of tools matching the pattern + */ +export function filterToolsByPattern(tools: T[], pattern: string): T[] { + return tools.filter((tool) => minimatch(tool.name, pattern, { nocase: true })); +} + +/** + * Check if a pattern contains glob special characters. + */ +export function isGlobPattern(pattern: string): boolean { + return /[*?[\]{}!]/.test(pattern); +} diff --git a/src/cli/list-output.ts b/src/cli/list-output.ts index 0153a3e..bd3bca9 100644 --- a/src/cli/list-output.ts +++ b/src/cli/list-output.ts @@ -5,6 +5,7 @@ import { formatErrorMessage, serializeConnectionIssue } from './json-output.js'; import { buildToolDoc } from './list-detail-helpers.js'; import type { ListSummaryResult, StatusCategory } from './list-format.js'; import { classifyListError } from './list-format.js'; +import { formatFunctionSignature } from './list-signature.js'; import { boldText, extraDimText } from './terminal.js'; import { formatTransportSummary } from './transport-utils.js'; @@ -119,6 +120,18 @@ function buildExampleOptions( return undefined; } +/** + * Print tools as compact function signatures for --brief mode. + */ +export function printBriefToolList(metadataEntries: ToolMetadata[], options?: { colorize?: boolean }): void { + for (const entry of metadataEntries) { + const signature = formatFunctionSignature(entry.tool.name, entry.options, entry.tool.outputSchema, { + colorize: options?.colorize, + }); + console.log(signature); + } +} + export function createEmptyStatusCounts(): Record { return { ok: 0, diff --git a/tests/cli-list-enhancement.test.ts b/tests/cli-list-enhancement.test.ts new file mode 100644 index 0000000..a9f5d89 --- /dev/null +++ b/tests/cli-list-enhancement.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cliModulePromise } from './fixtures/cli-list-fixtures.js'; + +describe('CLI list enhancement', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('extractListFlags', () => { + it('parses --brief flag', async () => { + const { extractListFlags } = await cliModulePromise; + const args = ['--brief', 'server']; + const flags = extractListFlags(args); + expect(flags.brief).toBe(true); + expect(args).toEqual(['server']); + }); + + it('throws error when --brief is used with --schema', async () => { + const { extractListFlags } = await cliModulePromise; + const args = ['--brief', '--schema', 'server']; + expect(() => extractListFlags(args)).toThrow(/--brief cannot be used with/); + }); + + it('throws error when --brief is used with --verbose', async () => { + const { extractListFlags } = await cliModulePromise; + const args = ['--brief', '--verbose', 'server']; + expect(() => extractListFlags(args)).toThrow(/--brief cannot be used with/); + }); + + it('throws error when --brief is used with --all-parameters (which disables requiredOnly)', async () => { + const { extractListFlags } = await cliModulePromise; + const args = ['--brief', '--all-parameters', 'server']; + expect(() => extractListFlags(args)).toThrow(/--brief cannot be used with/); + }); + }); + + describe('handleList', () => { + const mockTools = [ + { + name: 'add_user', + description: 'Add a user', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + { + name: 'list_users', + description: 'List users', + inputSchema: { + type: 'object', + properties: { limit: { type: 'number' } }, + }, + }, + { + name: 'delete_user', + description: 'Delete a user', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + ]; + + const mockRuntime = { + getDefinitions: () => [], + getDefinition: (name: string) => ({ + name, + command: { kind: 'stdio', command: 'noop' }, + source: { kind: 'local', path: '/tmp/config.json' }, + }), + listTools: () => Promise.resolve(mockTools), + }; + + it('filters tools by pattern', async () => { + const { handleList } = await cliModulePromise; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleList(mockRuntime as any, ['server', 'add_*']); + + // Verify that output contains only matching tools + const calls = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + // This is checking text output logic for detailed view + // Since it's detailed view, it should print tool details + // Assuming printToolDetail behavior + + // But verify we don't see delete_user + expect(calls).not.toContain('delete_user'); + // We might not easily verify "add_user" presence without knowing exact output format detail line + // checking side effect: filterToolsByPattern works implies handleList integration works + + consoleSpy.mockRestore(); + }); + + it('prints brief signatures for --brief', async () => { + const { handleList } = await cliModulePromise; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleList(mockRuntime as any, ['server', '--brief']); + + const calls = consoleSpy.mock.calls.map((c) => c[0]); + // Should match function signature format + expect(calls.some((line) => line.includes('function add_user(name?: string);'))).toBe(true); + expect(calls.some((line) => line.includes('function list_users(limit?: number);'))).toBe(true); + expect(calls.some((line) => line.includes('function delete_user(id?: string);'))).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('filters and prints brief signatures combined', async () => { + const { handleList } = await cliModulePromise; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleList(mockRuntime as any, ['server', 'add_*', '--brief']); + + const calls = consoleSpy.mock.calls.map((c) => c[0]); + expect(calls.some((line) => line.includes('function add_user'))).toBe(true); + expect(calls.some((line) => line.includes('function list_users'))).toBe(false); + + consoleSpy.mockRestore(); + }); + + it('handles no matches with pattern', async () => { + const { handleList } = await cliModulePromise; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleList(mockRuntime as any, ['server', 'nomatch_*']); + + const calls = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(calls).toContain("Tools: "); + + consoleSpy.mockRestore(); + }); + + it('handles no matches with pattern and brief', async () => { + const { handleList } = await cliModulePromise; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleList(mockRuntime as any, ['server', 'nomatch_*', '--brief']); + + const calls = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(calls).toContain("No tools matching pattern 'nomatch_*'."); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/tests/cli-list-flags.test.ts b/tests/cli-list-flags.test.ts index c755eb8..07a8f94 100644 --- a/tests/cli-list-flags.test.ts +++ b/tests/cli-list-flags.test.ts @@ -13,6 +13,7 @@ describe('CLI list flag parsing', () => { requiredOnly: true, includeSources: false, verbose: false, + brief: false, ephemeral: undefined, format: 'text', }); @@ -29,6 +30,7 @@ describe('CLI list flag parsing', () => { requiredOnly: false, includeSources: false, verbose: false, + brief: false, ephemeral: undefined, format: 'text', });