Improve function-call parsing and docs
This commit is contained in:
parent
dc30a79435
commit
fada75e6cf
@ -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
|
||||
|
||||
|
||||
32
README.md
32
README.md
@ -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 server’s 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).
|
||||
|
||||
@ -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(...)`).
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
@ -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
6
src/cli/errors.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export class CliUsageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CliUsageError';
|
||||
}
|
||||
}
|
||||
@ -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')`
|
||||
: '';
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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>();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user