feat(call): infer stdio commands and single-tool servers
This commit is contained in:
parent
cdc5f72aad
commit
3ad0668896
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user