Improve function-call parsing and docs

This commit is contained in:
Peter Steinberger 2025-11-06 23:52:54 +00:00
parent dc30a79435
commit fada75e6cf
11 changed files with 198 additions and 23 deletions

View File

@ -12,6 +12,7 @@
- Added a shared `extractEphemeralServerFlags` helper so `list`, `call`, and `auth` parse ad-hoc transports consistently, extended `mcporter auth` to accept bare URLs/`--http-url`/`--stdio`, and taught single-server listings to hint `mcporter auth https://…` when a 401 occurs. Docs (`README.md`, `docs/adhoc.md`, `docs/local.md`, `docs/call-heuristic.md`) and new tests (`tests/cli-auth.test.ts`, `tests/cli-ephemeral-flags.test.ts`, expanded `tests/cli-list.test.ts`) cover the workflow.
- Flag-style tool invocations now accept `key:value` and `key: value` alongside the existing `key=value` form, making commands like `mcporter context7.resolve-library-id libraryName:value` Just Work. Documented in the README/call syntax guide and covered by `tests/cli-call.test.ts`.
- Added `docs/tool-calling.md`, a cheatsheet summarizing every supported invocation pattern (inferred verbs, flag styles, function-call syntax, and ad-hoc URL workflows).
- Function-call syntax now allows unlabeled arguments; mcporter maps them to schema order after any explicitly named parameters (e.g. `mcporter 'context7.resolve-library-id("react")'`). Tests in `tests/cli-call.test.ts` cover the positional fallback.
## [0.3.0] - 2025-11-06

View File

@ -14,6 +14,16 @@ mcporter helps you lean into the "code execution" workflows highlighted in Anthr
## Quick Start
mcporter auto-discovers the MCP servers you already configured in Cursor, Claude Code/Desktop, Codex, or local overrides. You can try it immediately with `npx`--no installation required.
### Call syntax options
```bash
# Colon-delimited flags (shell-friendly)
npx mcporter call linear.create_comment issueId:ENG-123 body:'Looks good!'
# Function-call style (matches signatures from `mcporter list`)
npx mcporter call 'linear.create_comment(issueId: "ENG-123", body: "Looks good!")'
```
### List your MCP servers
@ -92,9 +102,24 @@ Helpful flags:
Timeouts default to 30 s; override with `MCPORTER_LIST_TIMEOUT` or `MCPORTER_CALL_TIMEOUT` when you expect slow startups.
### Try an MCP without editing config
```bash
# Point at an HTTPS MCP server directly
npx mcporter list --http-url https://mcp.linear.app/mcp --name linear
# Run a local stdio MCP server via Bun
npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
```
- Add `--persist config/mcporter.local.json` to save the inferred definition for future runs.
- Use `--allow-http` if you truly need to hit a cleartext endpoint.
- See [docs/adhoc.md](docs/adhoc.md) for a deep dive (env overrides, cwd, OAuth).
## Friendlier Tool Calls
- **Function-call syntax.** Instead of juggling `--flag value`, you can call tools as `mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'`. The parser supports nested objects/arrays and surfaces schema validation errors clearly. Deep dive in [docs/call-syntax.md](docs/call-syntax.md).
- **Function-call syntax.** Instead of juggling `--flag value`, you can call tools as `mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'`. The parser supports nested objects/arrays, lets you omit labels when you want to rely on schema order (e.g. `mcporter 'context7.resolve-library-id("react")'`), and surfaces schema validation errors clearly. Deep dive in [docs/call-syntax.md](docs/call-syntax.md).
- **Flag shorthand still works.** Prefer CLI-style arguments? Stick with `mcporter linear.create_issue title=value team=value`, `title=value`, `title:value`, or even `title: value`—the CLI now normalizes all three forms.
- **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).
@ -187,6 +212,9 @@ 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.
## Generate a Standalone CLI
Turn any server definition into a shareable CLI artifact:
@ -261,3 +289,5 @@ CI runs the same trio via GitHub Actions.
## License
MIT -- see [LICENSE](LICENSE).
Further reading: [docs/tool-calling.md](docs/tool-calling.md), [docs/call-syntax.md](docs/call-syntax.md), [docs/adhoc.md](docs/adhoc.md).

View File

@ -5,7 +5,7 @@
| Style | Example | Notes |
|-------|---------|-------|
| Flag-based (compatible) | `mcporter call linear.create_comment --issue-id LNR-123 --body "Hi"` | Use `key=value`, `key:value`, or `key: value` pairs—ideal for shell scripts. |
| Function-call (expressive) | `mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hi")'` | Mirrors the pseudo-TypeScript signature shown by `mcporter list`. |
| Function-call (expressive) | `mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hi")'` | Mirrors the pseudo-TypeScript signature shown by `mcporter list`; unlabeled values map to schema order. |
Both forms share the same validation pipeline, so required parameters, enums, and formats behave identically.
@ -36,7 +36,8 @@ Key details:
## Function-Call Syntax Details
- **Named arguments only**: `issueId: "123"` is required; positional arguments are rejected so we can reliably map schema names.
- **Named arguments preferred**: `issueId: "123"` keeps calls self-documenting. When labels are omitted, mcporter falls back to positional order defined by the tool schema.
- **Optional positional fallback**: omit labels when calling `mcporter 'context7.resolve-library-id("react")'`—arguments map to the schema order after any explicitly named parameters.
- **Literals supported**: strings, numbers, booleans, `null`, arrays, and nested objects. For strings containing spaces or commas, wrap the entire call in single quotes to keep the shell happy.
- **Error feedback**: invalid keys, unsupported expressions, or parser failures bubble up with actionable messages (`Unsupported argument expression: Identifier`, `Unable to parse call expression: …`).
- **Server selection**: You can embed the server in the expression (`linear.create_comment(...)`) or pass it separately (`--server linear create_comment(...)`).

View File

@ -31,10 +31,11 @@ mcporter call context7.resolve-library-id libraryName: value
```bash
mcporter call 'linear.create_issue(title: "Bug", team: "ENG")'
mcporter 'context7.resolve-library-id(libraryName: "react")'
mcporter 'context7.resolve-library-id("react")'
```
- Mirrors the pseudo-TypeScript signature printed by `mcporter list`.
- All arguments must be named (no positional order).
- You may omit labels and rely on the schema order—`mcporter 'context7.resolve-library-id("react")'` maps the first argument to `libraryName` automatically.
- Supports nested objects/arrays and gives detailed parser errors when the expression is malformed.
- Wrap the whole expression in quotes so the shell leaves parentheses/commas intact.

View File

@ -2,6 +2,7 @@
import fsPromises from 'node:fs/promises';
import { handleCall as runHandleCall } from './cli/call-command.js';
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './cli/adhoc-server.js';
import { CliUsageError } from './cli/errors.js';
import { inferCommandRouting } from './cli/command-inference.js';
import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js';
import { handleList } from './cli/list-command.js';
@ -724,6 +725,11 @@ Global flags:
if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
main().catch((error) => {
if (error instanceof CliUsageError) {
logError(error.message);
process.exit(1);
return;
}
const message = error instanceof Error ? error.message : String(error);
logError(message, error instanceof Error ? error : undefined);
process.exit(1);

View File

@ -1,6 +1,9 @@
import type { ServerToolInfo } from '../runtime.js';
import { createCallResult } 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';
import { extractOptions } from './generate/tools.js';
import { chooseClosestIdentifier, normalizeIdentifier } from './identifier-helpers.js';
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
import { type OutputFormat, printCallOutput, tailLogIfRequested } from './output-utils.js';
@ -13,6 +16,7 @@ interface CallArgsParseResult {
server?: string;
tool?: string;
args: Record<string, unknown>;
positionalArgs?: unknown[];
tailLog: boolean;
output: OutputFormat;
timeoutMs?: number;
@ -110,7 +114,12 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
}
if (positional.length > 0) {
const callExpression = parseCallExpressionFragment(positional[0] ?? '');
let callExpression: ReturnType<typeof parseCallExpressionFragment>;
try {
callExpression = parseCallExpressionFragment(positional[0] ?? '');
} catch (error) {
throw buildCallExpressionUsageError(error);
}
if (callExpression) {
positional.shift();
if (callExpression.server) {
@ -128,6 +137,9 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
}
result.tool = callExpression.tool;
Object.assign(result.args, callExpression.args);
if (callExpression.positionalArgs && callExpression.positionalArgs.length > 0) {
result.positionalArgs = [...(result.positionalArgs ?? []), ...callExpression.positionalArgs];
}
}
}
@ -246,7 +258,8 @@ export async function handleCall(
const { server, tool } = resolveCallTarget(parsed);
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const { result } = await invokeWithAutoCorrection(runtime, server, tool, parsed.args, timeoutMs);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
const { result } = await invokeWithAutoCorrection(runtime, server, tool, hydratedArgs, timeoutMs);
const wrapped = createCallResult(result);
printCallOutput(wrapped, result, parsed.output);
@ -307,6 +320,52 @@ function coerceValue(value: string): unknown {
return trimmed;
}
async function hydratePositionalArguments(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
server: string,
tool: string,
namedArgs: Record<string, unknown>,
positionalArgs: unknown[] | undefined
): Promise<Record<string, unknown>> {
if (!positionalArgs || positionalArgs.length === 0) {
return namedArgs;
}
// We need the schema order to know which field each positional argument maps to; pull the
// tool list with schemas instead of guessing locally so optional/required order stays correct.
const tools = await runtime.listTools(server, { includeSchema: true }).catch(() => undefined);
if (!tools) {
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
}
const toolInfo = tools.find((entry) => entry.name === tool);
if (!toolInfo) {
throw new Error(`Unknown tool '${tool}' on server '${server}'. Double-check the name or run mcporter list ${server}.`);
}
if (!toolInfo.inputSchema) {
throw new Error(`Tool '${tool}' does not expose an input schema; name positional arguments explicitly.`);
}
const options = extractOptions(toolInfo as ServerToolInfo);
if (options.length === 0) {
throw new Error(`Tool '${tool}' has no declared parameters; remove positional arguments.`);
}
// Respect whichever parameters the user already supplied by name so positional values only
// populate the fields that are still unset.
const remaining = options.filter((option) => !(option.property in namedArgs));
if (positionalArgs.length > remaining.length) {
throw new Error(
`Too many positional arguments (${positionalArgs.length}) supplied; only ${remaining.length} parameter${remaining.length === 1 ? '' : 's'} remain on ${tool}.`
);
}
const hydrated: Record<string, unknown> = { ...namedArgs };
positionalArgs.forEach((value, index) => {
const target = remaining[index];
if (!target) {
return;
}
hydrated[target.property] = value;
});
return hydrated;
}
type ToolResolution = { kind: 'auto-correct'; tool: string } | { kind: 'suggest'; tool: string };
async function invokeWithAutoCorrection(
@ -403,3 +462,19 @@ function extractMissingToolFromError(error: unknown): string | undefined {
const match = message.match(/Tool\s+([A-Za-z0-9._-]+)\s+not found/i);
return match?.[1];
}
function buildCallExpressionUsageError(error: unknown): CliUsageError {
const reason = error instanceof Error ? error.message : String(error ?? 'Unknown error');
const lines = [
'Unable to parse function-style call.',
`Reason: ${reason}`,
'',
'Examples:',
" mcporter 'context7.resolve-library-id(libraryName: \"react\")'",
" mcporter 'context7.resolve-library-id(\"react\")'",
' mcporter context7.resolve-library-id libraryName=react',
'',
'Tip: wrap the entire expression in single quotes so the shell preserves parentheses and commas.',
];
return new CliUsageError(lines.join('\n'));
}

View File

@ -13,6 +13,7 @@ interface ParsedCallExpression {
server?: string;
tool: string;
args: Record<string, unknown>;
positionalArgs?: unknown[];
}
const ACORN_OPTIONS = {
@ -62,19 +63,32 @@ export function parseCallExpressionFragment(raw: string): ParsedCallExpression |
};
}
if (callExpression.arguments.length > 1) {
throw new Error(
'Function-call syntax only supports named arguments. Separate values with commas inside an object.'
);
if (callExpression.arguments.length === 1 && callExpression.arguments[0]?.type === 'ObjectExpression') {
const argument = callExpression.arguments[0];
if (!argument || argument.type !== 'ObjectExpression') {
throw new Error('Function-call syntax requires named arguments (e.g. issueId: 123).');
}
const args = extractObject(argument);
return { ...splitPrefix(prefix), args };
}
const argument = callExpression.arguments[0];
if (!argument || argument.type !== 'ObjectExpression') {
throw new Error('Function-call syntax requires named arguments (e.g. issueId: 123).');
}
// At this point we know the call expression isn't a plain object literal, so we interpret
// whatever arguments remain positionally. We still reuse the literal extractor so nested
// arrays/objects stay supported.
const positionalArgs = callExpression.arguments.map((argument) => {
if (!argument) {
throw new Error('Unsupported empty argument in call expression.');
}
if (argument.type === 'SpreadElement') {
throw new Error('Spread elements are not supported in call expressions.');
}
if (!isSupportedValue(argument as Expression)) {
throw new Error(`Unsupported argument expression: ${argument.type}.`);
}
return extractValue(argument as Expression);
});
const args = extractObject(argument);
return { ...splitPrefix(prefix), args };
return { ...splitPrefix(prefix), args: {}, positionalArgs };
}
function splitPrefix(prefix: string): { server?: string; tool: string } {

6
src/cli/errors.ts Normal file
View File

@ -0,0 +1,6 @@
export class CliUsageError extends Error {
constructor(message: string) {
super(message);
this.name = 'CliUsageError';
}
}

View File

@ -238,6 +238,7 @@ export function renderToolCommand(
: '';
const { hiddenOptions } = selectDisplayOptions(tool.options, true);
const optionalSummary = hiddenOptions.length > 0 ? formatOptionalSummary(hiddenOptions, { colorize: false }) : '';
// Matching `mcporter list`, add a compact optional-parameter hint so generated CLIs stay familiar.
const optionalSnippet = optionalSummary
? `\n\t.addHelpText('afterAll', () => '\n${optionalSummary}\n')`
: '';

View File

@ -164,6 +164,8 @@ export function formatExampleBlock(
examples: string[],
options?: { maxExamples?: number; maxLength?: number }
): string[] {
// Keep examples deterministic: dedupe, cap the total, then apply the same ellipsis logic
// used by the list command so generators/CLIs display identical call hints.
const maxExamples = options?.maxExamples ?? 1;
const maxLength = options?.maxLength ?? 80;
return Array.from(new Set(examples))

View File

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { CliUsageError } from '../src/cli/errors.js';
process.env.MCPORTER_DISABLE_AUTORUN = '1';
const cliModulePromise = import('../src/cli.js');
@ -76,6 +77,14 @@ describe('CLI call argument parsing', () => {
expect(parsed.args).toEqual({ issueId: 'ISSUE-123', body: 'Hello', notify: false });
});
it('parses positional function-call arguments when labels are omitted', async () => {
const { parseCallArguments } = await cliModulePromise;
const parsed = parseCallArguments(['context7.resolve-library-id("value", 2)']);
expect(parsed.server).toBe('context7');
expect(parsed.tool).toBe('resolve-library-id');
expect(parsed.positionalArgs).toEqual(['value', 2]);
});
it('supports function-call syntax when the server is provided separately', async () => {
const { parseCallArguments } = await cliModulePromise;
const parsed = parseCallArguments(['--server', 'linear', 'create_comment(issueId: "123")']);
@ -91,13 +100,6 @@ describe('CLI call argument parsing', () => {
);
});
it('requires named arguments in the call expression', async () => {
const { parseCallArguments } = await cliModulePromise;
expect(() => parseCallArguments(['linear.create_comment("oops")'])).toThrow(
'Function-call syntax requires named arguments (e.g. issueId: 123).'
);
});
it('throws when trailing tokens lack key=value formatting', async () => {
const { parseCallArguments } = await cliModulePromise;
expect(() => parseCallArguments(['chrome-devtools', 'list_pages', 'oops'])).toThrow(
@ -105,6 +107,11 @@ describe('CLI call argument parsing', () => {
);
});
it('surfaces a helpful error when function-call syntax cannot be parsed', async () => {
const { parseCallArguments } = await cliModulePromise;
expect(() => parseCallArguments(['linear.create_comment(oops)'])).toThrow(CliUsageError);
});
it('aborts long-running tools when the timeout elapses', async () => {
vi.useFakeTimers();
try {
@ -221,6 +228,37 @@ describe('CLI call argument parsing', () => {
errorSpy.mockRestore();
});
it('maps positional function arguments using schema order', async () => {
const { handleCall } = await cliModulePromise;
const callTool = vi.fn().mockResolvedValue({ ok: true });
const listTools = vi.fn().mockResolvedValue([
{
name: 'resolve-library-id',
description: 'Lookup',
inputSchema: {
type: 'object',
properties: {
libraryName: { type: 'string' },
region: { type: 'string' },
},
required: ['libraryName'],
},
},
]);
const runtime = {
callTool,
listTools,
close: vi.fn().mockResolvedValue(undefined),
} as unknown as Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
await handleCall(runtime, ['context7.resolve-library-id("library", "us-east-1")']);
expect(callTool).toHaveBeenCalledWith('context7', 'resolve-library-id', {
args: { libraryName: 'library', region: 'us-east-1' },
});
});
it('registers an ad-hoc HTTP server when --http-url is provided', async () => {
const { handleCall } = await cliModulePromise;
const definitions = new Map<string, ServerDefinition>();