From fada75e6cfaffb0c87ee58d4de57ceb015a12034 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 6 Nov 2025 23:52:54 +0000 Subject: [PATCH] Improve function-call parsing and docs --- CHANGELOG.md | 1 + README.md | 32 ++++++++++++- docs/call-syntax.md | 5 +- docs/tool-calling.md | 3 +- src/cli.ts | 6 +++ src/cli/call-command.ts | 79 ++++++++++++++++++++++++++++++- src/cli/call-expression-parser.ts | 34 +++++++++---- src/cli/errors.ts | 6 +++ src/cli/generate/template.ts | 1 + src/cli/list-detail-helpers.ts | 2 + tests/cli-call.test.ts | 52 +++++++++++++++++--- 11 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 src/cli/errors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2e7e0..323d4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 497cebe..4ea6680 100644 --- a/README.md +++ b/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 ` 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). diff --git a/docs/call-syntax.md b/docs/call-syntax.md index 5ce9bb6..eb8c25b 100644 --- a/docs/call-syntax.md +++ b/docs/call-syntax.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(...)`). diff --git a/docs/tool-calling.md b/docs/tool-calling.md index f7edcaf..0fe0948 100644 --- a/docs/tool-calling.md +++ b/docs/tool-calling.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index 78d842a..afe0e82 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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); diff --git a/src/cli/call-command.ts b/src/cli/call-command.ts index ef102de..6cf3abc 100644 --- a/src/cli/call-command.ts +++ b/src/cli/call-command.ts @@ -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; + 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; + 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>, + server: string, + tool: string, + namedArgs: Record, + positionalArgs: unknown[] | undefined +): Promise> { + 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 = { ...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')); +} diff --git a/src/cli/call-expression-parser.ts b/src/cli/call-expression-parser.ts index d411758..2a5ae15 100644 --- a/src/cli/call-expression-parser.ts +++ b/src/cli/call-expression-parser.ts @@ -13,6 +13,7 @@ interface ParsedCallExpression { server?: string; tool: string; args: Record; + 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 } { diff --git a/src/cli/errors.ts b/src/cli/errors.ts new file mode 100644 index 0000000..46c356c --- /dev/null +++ b/src/cli/errors.ts @@ -0,0 +1,6 @@ +export class CliUsageError extends Error { + constructor(message: string) { + super(message); + this.name = 'CliUsageError'; + } +} diff --git a/src/cli/generate/template.ts b/src/cli/generate/template.ts index 29d60d3..50f0111 100644 --- a/src/cli/generate/template.ts +++ b/src/cli/generate/template.ts @@ -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')` : ''; diff --git a/src/cli/list-detail-helpers.ts b/src/cli/list-detail-helpers.ts index 7f3a9dd..a9c2a5c 100644 --- a/src/cli/list-detail-helpers.ts +++ b/src/cli/list-detail-helpers.ts @@ -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)) diff --git a/tests/cli-call.test.ts b/tests/cli-call.test.ts index 50fec1c..6ddef5c 100644 --- a/tests/cli-call.test.ts +++ b/tests/cli-call.test.ts @@ -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>; + + 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();