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); }); });