feat(call): infer stdio commands and single-tool servers

This commit is contained in:
Peter Steinberger 2025-11-10 18:43:04 +00:00
parent cdc5f72aad
commit 3ad0668896
6 changed files with 202 additions and 5 deletions

View File

@ -3,7 +3,8 @@
## [Unreleased]
### CLI & runtime
- _Nothing yet._
- `mcporter call "<stdio command>" ...` now auto-detects ad-hoc STDIO servers, so you can skip `--stdio/--stdio-arg` entirely and just quote the command you want to run.
- When a server exposes exactly one tool, `mcporter call` infers it automatically (and prints a dim log), letting one-tool servers like Vercel Domains run with only their arguments.
## [0.5.1] - 2025-11-10

View File

@ -121,6 +121,7 @@ npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
npx mcporter linear.list_issues # shorthand: infers `call`
VERCEL_ACCESS_TOKEN=sk_vercel_example npx mcporter call "npx -y vercel-domains-mcp" domain=answeroverflow.com # quoted stdio cmd + single-tool inference
```
> Tool calls understand a JavaScript-like call syntax, auto-correct near-miss tool names, and emit richer inline usage hints. See [docs/call-syntax.md](docs/call-syntax.md) for the grammar and [docs/call-heuristic.md](docs/call-heuristic.md) for the auto-correction rules.

View File

@ -136,6 +136,16 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
result.selector = positional.shift();
}
if (
!result.server &&
result.selector &&
shouldPromoteSelectorToCommand(result.selector) &&
!result.ephemeral?.stdioCommand
) {
result.ephemeral = { ...result.ephemeral, stdioCommand: result.selector };
result.selector = undefined;
}
const nextPositional = positional[0];
if (!result.tool && nextPositional !== undefined && !nextPositional.includes('=') && !callExpressionProvidedTool) {
result.tool = positional.shift();
@ -282,3 +292,20 @@ function buildCallExpressionUsageError(error: unknown): CliUsageError {
];
return new CliUsageError(lines.join('\n'));
}
function shouldPromoteSelectorToCommand(selector: string): boolean {
const trimmed = selector.trim();
if (!trimmed) {
return false;
}
if (/\s/.test(trimmed)) {
return true;
}
if (/^(?:\.{1,2}\/|~\/|\/)/.test(trimmed)) {
return true;
}
if (/^[A-Za-z]:\\/.test(trimmed) || trimmed.startsWith('\\\\')) {
return true;
}
return false;
}

View File

@ -69,7 +69,18 @@ export async function handleCall(
parsed.selector = prepared.target;
}
const { server, tool } = resolveCallTarget(parsed);
const target = resolveCallTarget(parsed, { allowMissingTool: true });
const server = target.server;
let tool = target.tool;
if (!server) {
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
}
if (!tool) {
tool = await inferSingleToolName(runtime, server);
if (!tool) {
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
}
}
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
@ -94,7 +105,14 @@ export async function handleCall(
dumpActiveHandles('after call (formatted result)');
}
function resolveCallTarget(parsed: CallArgsParseResult): { server: string; tool: string } {
interface ResolveCallTargetOptions {
allowMissingTool?: boolean;
}
function resolveCallTarget(
parsed: CallArgsParseResult,
options: ResolveCallTargetOptions = {}
): { server?: string; tool?: string } {
const selector = parsed.selector;
let server = parsed.server;
let tool = parsed.tool;
@ -105,14 +123,14 @@ function resolveCallTarget(parsed: CallArgsParseResult): { server: string; tool:
tool = right;
} else if (selector && !server) {
server = selector;
} else if (selector && !tool) {
} else if (selector && !tool && selector !== server) {
tool = selector;
}
if (!server) {
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
}
if (!tool) {
if (!tool && !options.allowMissingTool) {
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
}
@ -169,6 +187,22 @@ async function hydratePositionalArguments(
type ToolResolution = IdentifierResolution;
async function inferSingleToolName(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
server: string
): Promise<string | undefined> {
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
if (tools.length !== 1) {
return undefined;
}
const name = tools[0]?.tool.name;
if (!name) {
return undefined;
}
console.log(dimText(`[auto] ${server} exposes a single tool (${name}); using it.`));
return name;
}
async function invokeWithAutoCorrection(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
server: string,

View File

@ -5,6 +5,14 @@ process.env.MCPORTER_DISABLE_AUTORUN = '1';
const cliModulePromise = import('../src/cli.js');
describe('CLI call argument parsing', () => {
it('treats quoted stdio commands as ad-hoc servers without --stdio', async () => {
const { parseCallArguments } = await cliModulePromise;
const parsed = parseCallArguments(['npx -y vercel-domains-mcp', 'domain=answeroverflow.com']);
expect(parsed.selector).toBeUndefined();
expect(parsed.ephemeral?.stdioCommand).toBe('npx -y vercel-domains-mcp');
expect(parsed.args).toEqual({ domain: 'answeroverflow.com' });
});
it('falls back to default call timeout when env is empty', async () => {
vi.stubEnv('MCPORTER_CALL_TIMEOUT', '');
try {

View File

@ -1,9 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveEphemeralServer } from '../src/cli/adhoc-server.js';
import type { ServerDefinition } from '../src/config.js';
process.env.MCPORTER_DISABLE_AUTORUN = '1';
const cliModulePromise = import('../src/cli.js');
describe('CLI call execution behavior', () => {
it('auto-selects the sole tool when omitted', async () => {
const toolName = 'list_issues';
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub(
{
linear: [
{
name: toolName,
description: 'List issues',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number' },
},
required: [],
},
},
],
},
{
definitions: [
{
name: 'linear',
command: { kind: 'stdio', command: 'linear', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
],
}
);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['linear', 'limit=5']);
expect(callTool).toHaveBeenCalledWith('linear', toolName, { args: { limit: 5 } });
logSpy.mockRestore();
});
it('still requires an explicit tool when multiple are available', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub(
{
linear: [
{ name: 'list_issues', inputSchema: {} },
{ name: 'create_issue', inputSchema: {} },
],
},
{
definitions: [
{
name: 'linear',
command: { kind: 'stdio', command: 'linear', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
],
}
);
await expect(handleCall(runtime, ['linear'])).rejects.toThrow(
'Missing tool name. Provide it via <server>.<tool> or --tool.'
);
expect(callTool).not.toHaveBeenCalled();
});
it('runs quoted stdio commands without --stdio and infers the tool automatically', async () => {
const command = 'npx -y vercel-domains-mcp';
const { name: adhocName } = resolveEphemeralServer({ stdioCommand: command });
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub({
[adhocName]: [
{
name: 'getDomainAvailability',
inputSchema: {
type: 'object',
properties: { domain: { type: 'string' } },
required: ['domain'],
},
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, [command, 'domain=answeroverflow.com']);
expect(callTool).toHaveBeenCalledWith(adhocName, 'getDomainAvailability', {
args: { domain: 'answeroverflow.com' },
});
logSpy.mockRestore();
});
it('aborts long-running tools when the timeout elapses', async () => {
vi.useFakeTimers();
try {
@ -73,3 +159,43 @@ describe('CLI call execution behavior', () => {
errorSpy.mockRestore();
});
});
function createRuntimeStub(
toolCatalog: Record<
string,
Array<{
name: string;
description?: string;
inputSchema?: unknown;
}>
>,
options: { definitions?: ServerDefinition[] } = {}
): {
runtime: Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
callTool: ReturnType<typeof vi.fn>;
listTools: ReturnType<typeof vi.fn>;
} {
const definitions = new Map<string, ServerDefinition>();
for (const entry of options.definitions ?? []) {
definitions.set(entry.name, entry);
}
const callTool = vi.fn().mockResolvedValue({ ok: true });
const listTools = vi.fn().mockImplementation(async (server: string) => {
const tools = toolCatalog[server];
if (!tools) {
throw new Error(`Unknown MCP server '${server}'.`);
}
return tools;
});
const close = vi.fn().mockResolvedValue(undefined);
const runtime = {
getDefinitions: () => [...definitions.values()],
registerDefinition: vi.fn().mockImplementation((definition: ServerDefinition) => {
definitions.set(definition.name, definition);
}),
listTools,
callTool,
close,
} as unknown as Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
return { runtime, callTool, listTools };
}