feat: add compact list signatures

Co-authored-by: yuhp <yu.haip@gmail.com>
This commit is contained in:
Peter Steinberger 2026-05-04 06:52:34 +01:00
parent 5d8e64d5d5
commit 0e50f2b564
No known key found for this signature in database
13 changed files with 248 additions and 21 deletions

View File

@ -18,6 +18,7 @@
- Fail OAuth flows immediately when the server never creates an authorization URL, instead of waiting for a browser callback that cannot arrive. (Issue #115)
- Support `mcporter list server.tool --schema` to print a single tool's schema instead of the whole server. (Issue #116)
- Surface MCP server `instructions` from the initialize response in single-server `mcporter list` text and JSON output. (Issue #76)
- Add compact `mcporter list <server> --brief` / `--signatures` output for scanning signatures without doc blocks, examples, or schemas. (PR #144, thanks @yuhp)
### Config

View File

@ -204,7 +204,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
- **Unknown long flags fail fast.** `mcporter call server.tool --source import` now errors instead of silently turning `--source` into a positional tool argument. Use `source=import`, `--args '{"source":"import"}'`, or insert `--` before literal positional values that begin with `--`.
- **Cheatsheet.** See [docs/tool-calling.md](docs/tool-calling.md) for a quick comparison of every supported call style (auto-inferred verbs, flags, function-calls, and ad-hoc URLs).
- **Auto-correct.** If you typo a tool name, MCPorter inspects the servers tool catalog, retries when the edit distance is tiny, and otherwise prints a `Did you mean …?` hint. The heuristic (and how to tune it) is captured in [docs/call-heuristic.md](docs/call-heuristic.md).
- **Richer single-server output.** `mcporter list <server>` now prints TypeScript-style signatures, inline comments, return-shape hints, and command examples that mirror the new call syntax. Optional parameters stay hidden by default—add `--all-parameters` or `--schema` whenever you need the full JSON schema.
- **Richer single-server output.** `mcporter list <server>` now prints TypeScript-style signatures, inline comments, return-shape hints, and command examples that mirror the new call syntax. Optional parameters stay hidden by default—add `--all-parameters` or `--schema` whenever you need the full JSON schema. Prefer a tighter scan? `mcporter list <server> --brief` (or `--signatures`) keeps just the compact signatures and optional summaries.
## Installation
@ -298,7 +298,7 @@ Friendly ergonomics baked into the proxy and result helpers:
Drop down to `runtime.callTool()` whenever you need explicit control over arguments, metadata, or streaming options.
Call `mcporter list <server>` any time you need the TypeScript-style signature, optional parameter hints, and sample invocations that match the CLI's function-call syntax.
Call `mcporter list <server>` any time you need the TypeScript-style signature, optional parameter hints, and sample invocations that match the CLI's function-call syntax. Add `--brief` or `--signatures` when you only want compact signatures.
## Generate a Standalone CLI

View File

@ -40,6 +40,7 @@ Key details:
- Run `mcporter list <server> --all-parameters` whenever you want the full signature; the footer repeats `Optional parameters hidden; run with --all-parameters 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.
- Run `mcporter list <server> --brief` (or `--signatures`) when you only want the compact signatures and optional summaries without doc blocks or examples.
## Function-Call Syntax Details

View File

@ -18,9 +18,15 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
MCP initialization.
- With `server.tool`, prints just that tool; combine with `--schema` for a single
tool schema.
- Add `--brief` or `--signatures` with a server or `server.tool` target to keep
the server header/instructions and print compact signatures without doc
comments, examples, or schemas.
- Hidden alias: `list-tools` (kept for muscle memory; not advertised in help output).
- Hidden ad-hoc flag aliases: `--sse` for `--http-url`, `--insecure` for `--allow-http` (for plain HTTP testing).
- Flags:
- `--brief` compact single-server output; cannot be combined with `--json`,
`--schema`, `--verbose`, or `--all-parameters`.
- `--signatures` alias for `--brief`.
- `--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.

View File

@ -17,6 +17,7 @@ import {
createEmptyStatusCounts,
createUnknownResult,
type ListJsonServerEntry,
printBriefTool,
printSingleServerHeader,
printToolDetail,
summarizeStatusCounts,
@ -35,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'],
@ -75,13 +78,36 @@ export function extractListFlags(args: string[]): {
args.splice(index, 1);
continue;
}
if (token === '--brief' || token === '--signatures') {
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 };
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';
@ -110,6 +136,9 @@ export async function handleList(
target = prepared.target;
if (!target) {
if (flags.brief) {
throw new Error('--brief requires a server target.');
}
const previousStdioLogMode = setStdioLogMode('silent');
try {
const servers = runtime.getDefinitions();
@ -347,6 +376,20 @@ export async function handleList(
console.log('');
return;
}
if (flags.brief) {
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printBriefTool(definition, entry, flags.requiredOnly);
optionalOmitted ||= detail.optionalOmitted;
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
}
console.log(summaryLine);
console.log('');
return;
}
const examples: string[] = [];
let optionalOmitted = false;
for (const entry of metadataEntries) {
@ -406,6 +449,8 @@ export function printListHelp(): void {
' --yes Skip confirmation prompts when persisting.',
'',
'Display flags:',
' --brief Show compact signatures only for a single server.',
' --signatures Alias for --brief.',
' --schema Show tool schemas when listing servers.',
' --all-parameters Include optional parameters in tool docs.',
' --json Emit a JSON summary instead of text.',
@ -416,6 +461,8 @@ export function printListHelp(): void {
'Examples:',
' mcporter list',
' mcporter list linear --schema',
' mcporter list linear --brief',
' mcporter list linear.list_issues --signatures',
' mcporter list https://mcp.example.com/mcp',
' mcporter list --http-url https://localhost:3333/mcp --schema',
];
@ -537,19 +584,8 @@ async function loadServerInstructions(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
serverName: string
): Promise<string | undefined> {
if (typeof runtime.connect !== 'function') {
return undefined;
}
try {
const context = await runtime.connect(serverName);
const instructions =
typeof context.client.getInstructions === 'function' ? context.client.getInstructions() : undefined;
if (typeof instructions !== 'string') {
return undefined;
}
const trimmed = instructions.trim();
return trimmed.length > 0 ? trimmed : undefined;
} catch {
if (typeof runtime.getInstructions !== 'function') {
return undefined;
}
return runtime.getInstructions(serverName);
}

View File

@ -13,6 +13,10 @@ export interface ToolDetailResult {
optionalOmitted: boolean;
}
export interface ToolBriefResult {
optionalOmitted: boolean;
}
export interface ListJsonServerEntry {
name: string;
status: StatusCategory;
@ -122,6 +126,30 @@ export function printToolDetail(
};
}
export function printBriefTool(
definition: ReturnType<Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>['getDefinition']>,
metadata: ToolMetadata,
requiredOnly: boolean
): ToolBriefResult {
const doc = buildToolDoc({
serverName: definition.name,
toolName: metadata.tool.name,
description: metadata.tool.description,
outputSchema: metadata.tool.outputSchema,
options: metadata.options,
requiredOnly,
colorize: true,
});
console.log(` ${doc.signature}`);
if (doc.optionalSummary && requiredOnly) {
console.log(` ${doc.optionalSummary}`);
}
console.log('');
return {
optionalOmitted: doc.hiddenOptions.length > 0,
};
}
function buildExampleOptions(
definition: ReturnType<Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>['getDefinition']>
): { selector?: string; wrapExpression?: boolean } | undefined {

View File

@ -47,6 +47,10 @@ class KeepAliveRuntime implements Runtime {
}
}
async getInstructions(server: string): Promise<string | undefined> {
return this.base.getInstructions?.(server);
}
async listTools(server: string, options?: ListToolsOptions): Promise<Awaited<ReturnType<Runtime['listTools']>>> {
if (this.shouldUseDaemon(server)) {
return (await this.invokeWithRestart(server, 'listTools', () =>

View File

@ -59,6 +59,7 @@ export interface Runtime {
getDefinitions(): ServerDefinition[];
getDefinition(server: string): ServerDefinition;
registerDefinition(definition: ServerDefinition, options?: { overwrite?: boolean }): void;
getInstructions?(server: string): Promise<string | undefined>;
listTools(server: string, options?: ListToolsOptions): Promise<ServerToolInfo[]>;
callTool(server: string, toolName: string, options?: CallOptions): Promise<unknown>;
listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown>;
@ -152,6 +153,25 @@ class McpRuntime implements Runtime {
this.clients.delete(definition.name);
}
async getInstructions(server: string): Promise<string | undefined> {
const contextPromise = this.clients.get(server.trim());
if (!contextPromise) {
return undefined;
}
try {
const context = await contextPromise;
const instructions =
typeof context.client.getInstructions === 'function' ? context.client.getInstructions() : undefined;
if (typeof instructions !== 'string') {
return undefined;
}
const trimmed = instructions.trim();
return trimmed.length > 0 ? trimmed : undefined;
} catch {
return undefined;
}
}
// listTools queries tool metadata and optionally includes schemas when requested.
async listTools(server: string, options: ListToolsOptions = {}): Promise<ServerToolInfo[]> {
// Toggle auto authorization so list can run without forcing OAuth flows.

View File

@ -8,6 +8,7 @@ describe('CLI list flag parsing', () => {
const args = ['--timeout', '7500', '--schema', 'server'];
const flags = extractListFlags(args);
expect(flags).toEqual({
brief: false,
schema: true,
timeoutMs: 7500,
requiredOnly: true,
@ -24,6 +25,7 @@ describe('CLI list flag parsing', () => {
const args = ['--all-parameters', 'server'];
const flags = extractListFlags(args);
expect(flags).toEqual({
brief: false,
schema: false,
timeoutMs: undefined,
requiredOnly: false,
@ -40,9 +42,33 @@ describe('CLI list flag parsing', () => {
const args = ['--json', 'server'];
const flags = extractListFlags(args);
expect(flags.format).toBe('json');
expect(flags.brief).toBe(false);
expect(args).toEqual(['server']);
});
it('parses --brief and --signatures aliases', async () => {
const { extractListFlags } = await cliModulePromise;
const briefArgs = ['--brief', 'server'];
const briefFlags = extractListFlags(briefArgs);
expect(briefFlags.brief).toBe(true);
expect(briefArgs).toEqual(['server']);
const signatureArgs = ['--signatures', 'server'];
const signatureFlags = extractListFlags(signatureArgs);
expect(signatureFlags.brief).toBe(true);
expect(signatureArgs).toEqual(['server']);
});
it('rejects --brief with incompatible display flags', async () => {
const { extractListFlags } = await cliModulePromise;
expect(() => extractListFlags(['--brief', '--json', 'server'])).toThrow('--brief cannot be used with --json');
expect(() => extractListFlags(['--brief', '--schema', 'server'])).toThrow('--brief cannot be used with --schema');
expect(() => extractListFlags(['--brief', '--verbose', 'server'])).toThrow('--brief cannot be used with --verbose');
expect(() => extractListFlags(['--brief', '--all-parameters', 'server'])).toThrow(
'--brief cannot be used with --all-parameters'
);
});
it('treats --sse as a hidden alias for --http-url in ad-hoc mode', async () => {
const { extractListFlags } = await cliModulePromise;
const args = ['--sse', 'https://mcp.example.com/sse', 'list'];

View File

@ -159,11 +159,7 @@ describe('CLI list formatting', () => {
getDefinitions: () => [definition],
getDefinition: () => definition,
listTools: vi.fn().mockResolvedValue([{ name: 'search_assets' }]),
connect: vi.fn().mockResolvedValue({
client: {
getInstructions: () => 'Use asset IDs from search results when calling mutation tools.',
},
}),
getInstructions: vi.fn().mockResolvedValue('Use asset IDs from search results when calling mutation tools.'),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@ -179,6 +175,79 @@ describe('CLI list formatting', () => {
logSpy.mockRestore();
});
it('prints compact signatures for single server listings with --brief', async () => {
const { handleList } = await cliModulePromise;
const listToolsSpy = vi.fn((_name: string, options?: { includeSchema?: boolean }) =>
Promise.resolve([buildLinearDocumentsTool(options?.includeSchema)])
);
const runtime = {
getDefinitions: () => [linearDefinition],
getDefinition: () => linearDefinition,
listTools: listToolsSpy,
getInstructions: vi.fn().mockResolvedValue('Use Linear IDs from list operations in mutation tools.'),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleList(runtime, ['linear', '--brief']);
const lines = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
expect(lines.some((line) => line.includes('Instructions: Use Linear IDs'))).toBe(true);
expect(lines.some((line) => line.trim().startsWith('/**'))).toBe(false);
expect(lines.some((line) => line.includes('Examples:'))).toBe(false);
expect(lines.some((line) => line.includes('@param'))).toBe(false);
expect(lines.some((line) => line.includes('function list_documents('))).toBe(true);
expect(
lines.some((line) => line.includes('// optional (4): projectId, initiativeId, creatorId, includeArchived'))
).toBe(true);
expect(
lines.some((line) => line.includes('Optional parameters hidden; run with --all-parameters to view all fields'))
).toBe(true);
expect(listToolsSpy).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
logSpy.mockRestore();
});
it('prints compact signatures for selected tools with --signatures', async () => {
const { handleList } = await cliModulePromise;
const listToolsSpy = vi.fn((_name: string, options?: { includeSchema?: boolean }) =>
Promise.resolve([
buildLinearDocumentsTool(options?.includeSchema),
{
name: 'create_comment',
description: 'Create a comment on a specific Linear issue',
inputSchema: options?.includeSchema
? {
type: 'object',
properties: {
issueId: { type: 'string', description: 'The issue ID' },
body: { type: 'string', description: 'Comment body as Markdown' },
},
required: ['issueId', 'body'],
}
: undefined,
},
])
);
const runtime = {
getDefinitions: () => [linearDefinition],
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.create_comment', '--signatures']);
const lines = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
expect(lines.some((line) => line.includes('function create_comment('))).toBe(true);
expect(lines.some((line) => line.includes('function list_documents('))).toBe(false);
expect(lines.some((line) => line.includes('Examples:'))).toBe(false);
expect(lines.find((line) => line.includes('HTTP https://example.com/mcp'))).toMatch(/1 tool/);
logSpy.mockRestore();
});
it('prints only the selected tool when listing server.tool with schemas', async () => {
const { handleList } = await cliModulePromise;
const listToolsSpy = vi.fn((_name: string, options?: { includeSchema?: boolean }) =>

View File

@ -28,6 +28,8 @@ describe('mcporter list help shortcut', () => {
await runCli(['list', '--help']);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage: mcporter list'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--brief'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--signatures'));
expect(process.exitCode).toBe(0);
});

View File

@ -35,6 +35,10 @@ class FakeRuntime implements Runtime {
// no-op for tests
}
async getInstructions(): Promise<string | undefined> {
return undefined;
}
async listTools(server: string, options?: ListToolsOptions): Promise<Awaited<ReturnType<Runtime['listTools']>>> {
return await this.listToolsMock(server, options);
}

View File

@ -5,6 +5,7 @@ import {
buildAuthCommandHint,
buildJsonListEntry,
createEmptyStatusCounts,
printBriefTool,
printSingleServerHeader,
printToolDetail,
} from '../src/cli/list-output.js';
@ -54,6 +55,35 @@ describe('list output helpers', () => {
logSpy.mockRestore();
});
it('prints brief tool signatures without examples or doc comments', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const tool: ServerToolInfo = {
name: 'add',
description: 'Add numbers',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First operand' },
b: { type: 'number', description: 'Second operand' },
format: { type: 'string', enum: ['json', 'markdown'], description: 'Format' },
projectId: { type: 'string', description: 'Project context' },
initiativeId: { type: 'string', description: 'Initiative context' },
creatorId: { type: 'string', description: 'Creator filter' },
},
required: ['a', 'b'],
},
outputSchema: { type: 'number' },
};
const metadata = buildToolMetadata(tool);
const detail = printBriefTool(definition, metadata, true);
const lines = logSpy.mock.calls.map((call) => call.join(' '));
expect(lines.some((line) => line.includes('function add('))).toBe(true);
expect(lines.some((line) => line.includes('@param a'))).toBe(false);
expect(lines.some((line) => line.includes('Examples:'))).toBe(false);
expect(detail.optionalOmitted).toBe(true);
logSpy.mockRestore();
});
it('prints URL-based examples for ad-hoc HTTP servers', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const adhocDefinition: ServerDefinition = {