247 lines
9.3 KiB
TypeScript
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);
|
|
});
|
|
});
|