mcporter/tests/call-arguments.test.ts
2026-06-22 14:03:17 +08:00

247 lines
9.3 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { parseCallArguments } from '../src/cli/call-arguments.js';
describe('parseCallArguments', () => {
it('parses legacy selector + key=value pairs', () => {
const args = ['linear.list_documents', 'limit=5', 'format=json'];
const parsed = parseCallArguments([...args]);
expect(parsed.selector).toBe('linear.list_documents');
expect(parsed.tool).toBeUndefined();
expect(parsed.args.limit).toBe(5);
expect(parsed.args.format).toBe('json');
});
it.each(['--server', '--mcp'] as const)('captures %s as server override', (flag) => {
const parsed = parseCallArguments([flag, 'linear', 'list_documents']);
expect(parsed.server).toBe('linear');
expect(parsed.tool).toBe('list_documents');
});
it('consumes function-style call expressions with HTTP selectors', () => {
const call = 'https://example.com/mcp.getComponents(limit: 3, projectId: "123")';
const parsed = parseCallArguments([call]);
expect(parsed.server).toBe('https://example.com/mcp');
expect(parsed.tool).toBe('getComponents');
expect(parsed.args.limit).toBe(3);
expect(parsed.args.projectId).toBe('123');
});
it('merges --args JSON blobs with positional fragments', () => {
const parsed = parseCallArguments([
'--args',
'{"query":"open issues"}',
'linear',
'list_documents',
'orderBy=updatedAt',
]);
expect(parsed.selector).toBe('linear');
expect(parsed.tool).toBe('list_documents');
expect(parsed.args.query).toBe('open issues');
expect(parsed.args.orderBy).toBe('updatedAt');
});
it('parses generic --key value flags as named tool arguments', () => {
const parsed = parseCallArguments([
'email.send_email',
'--to',
'["miguel@example.com"]',
'--subject',
'Test',
'--save-to-drafts',
'true',
'--limit=5',
]);
expect(parsed.args).toEqual({
to: ['miguel@example.com'],
subject: 'Test',
saveToDrafts: true,
limit: 5,
});
expect(parsed.schemaStringCoercionCandidates).toEqual({ limit: '5' });
});
it('merges --json object payloads as an alias for --args', () => {
const parsed = parseCallArguments([
'email.send_email',
'--json',
'{"to":["miguel@example.com"],"subject":"Test","saveToDrafts":true}',
'--text',
'Hello',
]);
expect(parsed.args).toEqual({
to: ['miguel@example.com'],
subject: 'Test',
saveToDrafts: true,
text: 'Hello',
});
});
it('reads JSON object payloads from stdin when --json - is used', () => {
const readFileSync = vi
.spyOn(fs, 'readFileSync')
.mockReturnValueOnce('{"to":["miguel@example.com"],"subject":"Test"}');
try {
const parsed = parseCallArguments(['email.send_email', '--json', '-']);
expect(parsed.args).toEqual({
to: ['miguel@example.com'],
subject: 'Test',
});
expect(readFileSync).toHaveBeenCalledWith(0, 'utf8');
} finally {
readFileSync.mockRestore();
}
});
it('reads exact UTF-8 text from @path named argument values', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const payloadPath = path.join(tempDir, 'payload.txt');
fs.writeFileSync(payloadPath, 'first line\nsecond line\n', 'utf8');
try {
const parsed = parseCallArguments(['server.tool', `body=@${payloadPath}`]);
expect(parsed.args.body).toBe('first line\nsecond line\n');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('supports @path through generic long tool flags', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const payloadPath = path.join(tempDir, 'payload.txt');
fs.writeFileSync(payloadPath, 'from file', 'utf8');
try {
const parsed = parseCallArguments(['server.tool', '--body', `@${payloadPath}`]);
expect(parsed.args.body).toBe('from file');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('preserves whitespace-only generic long flag values', () => {
const parsed = parseCallArguments(['server.tool', '--body', ' ']);
expect(parsed.args.body).toBe(' ');
});
it('uses @@ to preserve a literal leading @ without reading a file', () => {
const parsed = parseCallArguments(['server.tool', 'body=@@literal']);
expect(parsed.args.body).toBe('@literal');
});
it('reports missing and non-UTF-8 argument files before transport', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
const invalidPath = path.join(tempDir, 'invalid.bin');
fs.writeFileSync(invalidPath, Buffer.from([0xc3, 0x28]));
try {
expect(() => parseCallArguments(['server.tool', `body=@${path.join(tempDir, 'missing.txt')}`])).toThrow(
/Unable to read argument file/
);
expect(() => parseCallArguments(['server.tool', `body=@${invalidPath}`])).toThrow(/not valid UTF-8 text/);
expect(() => parseCallArguments(['server.tool', 'body=@'])).toThrow(/requires a path/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('throws when generic long flags are missing a value', () => {
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
});
it('treats values after -- as literal positional arguments', () => {
const parsed = parseCallArguments(['server.tool', '--', '--source', 'import', '--raw=true']);
expect(parsed.selector).toBe('server.tool');
expect(parsed.positionalArgs).toEqual(['--source', 'import', '--raw=true']);
});
it('throws when flags conflict with call expression content', () => {
expect(() => parseCallArguments(['--server', 'linear', 'cursor.list_documents(limit:1)'])).toThrow(
/Conflicting server names/
);
});
it('treats key:=value as an alias for key=value without keeping a trailing colon', () => {
const parsed = parseCallArguments(['schwab.placeOrder', 'price:=5.20', 'quantity:=0', 'limit:=10']);
expect(parsed.args.price).toBe('5.20');
expect(parsed.args.quantity).toBe(0);
expect(parsed.args.limit).toBe(10);
expect(parsed.schemaStringCoercionCandidates).toEqual({ quantity: '0', limit: '10' });
expect(parsed.args).not.toHaveProperty('price:');
});
it('leaves := inside values untouched', () => {
const parsed = parseCallArguments(['server.tool', 'expr=value:=x']);
expect(parsed.args.expr).toBe('value:=x');
expect(parsed.args).not.toHaveProperty('expr:');
});
it('warns when colon-style arguments omit a value', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const parsed = parseCallArguments(['iterm-mcp.write_to_terminal', 'command:']);
expect(parsed.args.command).toBe('');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[mcporter] Argument 'command' was provided without a value.")
);
warnSpy.mockRestore();
});
it.each([
['default', [], 123456, 'number'],
['raw-strings', ['--raw-strings'], '123456', 'string'],
['no-coerce', ['--no-coerce'], '123456', 'string'],
] as const)('handles numeric coercion in %s mode', (_mode, flags, expected, expectedType) => {
const parsed = parseCallArguments([...flags, 'server.tool', 'code=123456']);
expect(parsed.args.code).toBe(expected);
expect(typeof parsed.args.code).toBe(expectedType);
});
it('preserves leading zeros when --raw-strings flag is used', () => {
const parsed = parseCallArguments(['--raw-strings', 'server.tool', 'pin=000123']);
expect(parsed.args.pin).toBe('000123');
expect(typeof parsed.args.pin).toBe('string');
});
it('still coerces booleans, nulls, and JSON with --raw-strings', () => {
const parsed = parseCallArguments(['--raw-strings', 'server.tool', 'enabled=true', 'value=null', 'meta={"a":1}']);
expect(parsed.args.enabled).toBe(true);
expect(parsed.args.value).toBeNull();
expect(parsed.args.meta).toEqual({ a: 1 });
});
it('keeps every value as a string when --no-coerce alias is used', () => {
const parsed = parseCallArguments([
'--no-coerce',
'server.tool',
'id=007',
'enabled=true',
'value=null',
'meta={"a":1}',
'123',
]);
expect(parsed.args.id).toBe('007');
expect(parsed.args.enabled).toBe('true');
expect(parsed.args.value).toBe('null');
expect(parsed.args.meta).toBe('{"a":1}');
expect(typeof parsed.args.id).toBe('string');
expect(parsed.positionalArgs).toEqual(['123']);
});
it('captures --no-oauth as a runtime flag instead of a tool argument', () => {
const parsed = parseCallArguments(['server.tool', '--no-oauth', 'limit=5']);
expect(parsed.disableOAuth).toBe(true);
expect(parsed.args).toEqual({ limit: 5 });
});
it('captures --save-images output directory', () => {
const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
expect(parsed.saveImagesDir).toBe('./tmp/images');
});
it.each([
['--save-images', /--save-images requires a directory path/],
['--args', /--args requires a JSON value/],
] as const)('throws when %s is missing a value', (flag, expectedError) => {
expect(() => parseCallArguments([flag])).toThrow(expectedError);
});
});