ai-cli-mcp 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/dist/__tests__/e2e.test.js +232 -0
  3. package/dist/__tests__/edge-cases.test.js +135 -0
  4. package/dist/__tests__/error-cases.test.js +291 -0
  5. package/dist/__tests__/mocks.js +32 -0
  6. package/dist/__tests__/model-alias.test.js +36 -0
  7. package/dist/__tests__/process-management.test.js +630 -0
  8. package/dist/__tests__/server.test.js +681 -0
  9. package/dist/__tests__/setup.js +11 -0
  10. package/dist/__tests__/utils/claude-mock.js +80 -0
  11. package/dist/__tests__/utils/mcp-client.js +104 -0
  12. package/dist/__tests__/utils/persistent-mock.js +25 -0
  13. package/dist/__tests__/utils/test-helpers.js +11 -0
  14. package/dist/__tests__/validation.test.js +235 -0
  15. package/dist/__tests__/version-print.test.js +69 -0
  16. package/dist/__tests__/wait.test.js +229 -0
  17. package/dist/parsers.js +68 -0
  18. package/dist/server.js +774 -0
  19. package/package.json +1 -1
  20. package/src/__tests__/e2e.test.ts +16 -24
  21. package/src/__tests__/error-cases.test.ts +8 -17
  22. package/src/__tests__/process-management.test.ts +22 -24
  23. package/src/__tests__/validation.test.ts +58 -36
  24. package/src/server.ts +6 -2
  25. package/data/rooms/refactor-haiku-alias-main/messages.jsonl +0 -5
  26. package/data/rooms/refactor-haiku-alias-main/presence.json +0 -20
  27. package/data/rooms.json +0 -10
  28. package/hello.txt +0 -3
  29. package/implementation-log.md +0 -110
  30. package/implementation-plan.md +0 -189
  31. package/investigation-report.md +0 -135
  32. package/quality-score.json +0 -47
  33. package/refactoring-requirements.md +0 -25
  34. package/review-report.md +0 -132
  35. package/test-results.md +0 -119
  36. package/xx.txt +0 -1
@@ -0,0 +1,11 @@
1
+ // Global test setup
2
+ import { beforeAll, afterAll } from 'vitest';
3
+ import { getSharedMock, cleanupSharedMock } from './utils/persistent-mock.js';
4
+ beforeAll(async () => {
5
+ console.error('[TEST SETUP] Creating shared mock for all tests...');
6
+ await getSharedMock();
7
+ });
8
+ afterAll(async () => {
9
+ console.error('[TEST SETUP] Cleaning up shared mock...');
10
+ await cleanupSharedMock();
11
+ });
@@ -0,0 +1,80 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ /**
4
+ * Mock Claude CLI for testing
5
+ * This creates a fake Claude CLI that can be used during testing
6
+ */
7
+ export class ClaudeMock {
8
+ mockPath;
9
+ responses = new Map();
10
+ constructor(binaryName = 'claude') {
11
+ // Always use /tmp directory for mocks in tests
12
+ this.mockPath = join('/tmp', 'claude-code-test-mock', binaryName);
13
+ }
14
+ /**
15
+ * Setup the mock Claude CLI
16
+ */
17
+ async setup() {
18
+ const dir = dirname(this.mockPath);
19
+ if (!existsSync(dir)) {
20
+ mkdirSync(dir, { recursive: true });
21
+ }
22
+ // Create a simple bash script that echoes responses
23
+ const mockScript = `#!/bin/bash
24
+ # Mock Claude CLI for testing
25
+
26
+ # Extract the prompt from arguments
27
+ prompt=""
28
+ verbose=false
29
+ while [[ $# -gt 0 ]]; do
30
+ case $1 in
31
+ -p|--prompt)
32
+ prompt="$2"
33
+ shift 2
34
+ ;;
35
+ --verbose)
36
+ verbose=true
37
+ shift
38
+ ;;
39
+ --yes|-y|--dangerously-skip-permissions)
40
+ shift
41
+ ;;
42
+ *)
43
+ shift
44
+ ;;
45
+ esac
46
+ done
47
+
48
+ # Mock responses based on prompt
49
+ if [[ "$prompt" == *"create"* ]]; then
50
+ echo "Created file successfully"
51
+ elif [[ "$prompt" == *"Create"* ]]; then
52
+ echo "Created file successfully"
53
+ elif [[ "$prompt" == *"git"* ]] && [[ "$prompt" == *"commit"* ]]; then
54
+ echo "Committed changes successfully"
55
+ elif [[ "$prompt" == *"error"* ]]; then
56
+ echo "Error: Mock error response" >&2
57
+ exit 1
58
+ else
59
+ echo "Command executed successfully"
60
+ fi
61
+ `;
62
+ writeFileSync(this.mockPath, mockScript);
63
+ // Make executable
64
+ const { chmod } = await import('node:fs/promises');
65
+ await chmod(this.mockPath, 0o755);
66
+ }
67
+ /**
68
+ * Cleanup the mock Claude CLI
69
+ */
70
+ async cleanup() {
71
+ const { rm } = await import('node:fs/promises');
72
+ await rm(this.mockPath, { force: true });
73
+ }
74
+ /**
75
+ * Add a mock response for a specific prompt pattern
76
+ */
77
+ addResponse(pattern, response) {
78
+ this.responses.set(pattern, response);
79
+ }
80
+ }
@@ -0,0 +1,104 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ /**
4
+ * Mock MCP client for testing the server
5
+ */
6
+ export class MCPTestClient extends EventEmitter {
7
+ serverPath;
8
+ env;
9
+ server = null;
10
+ requestId = 0;
11
+ pendingRequests = new Map();
12
+ buffer = '';
13
+ constructor(serverPath, env = {}) {
14
+ super();
15
+ this.serverPath = serverPath;
16
+ this.env = env;
17
+ }
18
+ async connect() {
19
+ return new Promise((resolve, reject) => {
20
+ this.server = spawn('node', [this.serverPath], {
21
+ env: { ...process.env, ...this.env },
22
+ stdio: ['pipe', 'pipe', 'pipe'],
23
+ });
24
+ this.server.stdout?.on('data', (data) => {
25
+ this.handleData(data.toString());
26
+ });
27
+ this.server.stderr?.on('data', (data) => {
28
+ console.error('Server stderr:', data.toString());
29
+ });
30
+ this.server.on('error', (error) => {
31
+ reject(error);
32
+ });
33
+ this.server.on('spawn', () => {
34
+ resolve();
35
+ });
36
+ });
37
+ }
38
+ async disconnect() {
39
+ if (this.server) {
40
+ this.server.kill();
41
+ await new Promise((resolve) => {
42
+ this.server.on('exit', resolve);
43
+ });
44
+ this.server = null;
45
+ }
46
+ }
47
+ handleData(data) {
48
+ this.buffer += data;
49
+ const lines = this.buffer.split('\n');
50
+ this.buffer = lines.pop() || '';
51
+ for (const line of lines) {
52
+ if (!line.trim())
53
+ continue;
54
+ try {
55
+ const response = JSON.parse(line);
56
+ if (response.id && this.pendingRequests.has(response.id)) {
57
+ const pending = this.pendingRequests.get(response.id);
58
+ this.pendingRequests.delete(response.id);
59
+ pending.resolve(response);
60
+ }
61
+ else {
62
+ this.emit('notification', response);
63
+ }
64
+ }
65
+ catch (error) {
66
+ console.error('Failed to parse response:', line, error);
67
+ }
68
+ }
69
+ }
70
+ async sendRequest(method, params) {
71
+ const id = ++this.requestId;
72
+ const request = {
73
+ jsonrpc: '2.0',
74
+ method,
75
+ params,
76
+ id,
77
+ };
78
+ return new Promise((resolve, reject) => {
79
+ this.pendingRequests.set(id, { resolve, reject });
80
+ this.server?.stdin?.write(JSON.stringify(request) + '\n');
81
+ // Timeout after 30 seconds
82
+ setTimeout(() => {
83
+ if (this.pendingRequests.has(id)) {
84
+ this.pendingRequests.delete(id);
85
+ reject(new Error(`Request ${id} timed out`));
86
+ }
87
+ }, 30000);
88
+ });
89
+ }
90
+ async callTool(name, args) {
91
+ const response = await this.sendRequest('tools/call', {
92
+ name,
93
+ arguments: args,
94
+ });
95
+ if (response.error) {
96
+ throw new Error(`Tool call failed: ${response.error.message}`);
97
+ }
98
+ return response.result?.content;
99
+ }
100
+ async listTools() {
101
+ const response = await this.sendRequest('tools/list');
102
+ return response.result?.tools || [];
103
+ }
104
+ }
@@ -0,0 +1,25 @@
1
+ import { ClaudeMock } from './claude-mock.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ let sharedMock = null;
5
+ export async function getSharedMock() {
6
+ if (!sharedMock) {
7
+ sharedMock = new ClaudeMock('claudeMocked');
8
+ }
9
+ // Always ensure mock exists
10
+ const mockPath = join('/tmp', 'claude-code-test-mock', 'claudeMocked');
11
+ if (!existsSync(mockPath)) {
12
+ console.error(`[DEBUG] Mock not found at ${mockPath}, creating it...`);
13
+ await sharedMock.setup();
14
+ }
15
+ else {
16
+ console.error(`[DEBUG] Mock already exists at ${mockPath}`);
17
+ }
18
+ return sharedMock;
19
+ }
20
+ export async function cleanupSharedMock() {
21
+ if (sharedMock) {
22
+ await sharedMock.cleanup();
23
+ sharedMock = null;
24
+ }
25
+ }
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function verifyMockExists(binaryName) {
4
+ const mockPath = join('/tmp', 'claude-code-test-mock', binaryName);
5
+ return existsSync(mockPath);
6
+ }
7
+ export async function ensureMockExists(mock) {
8
+ if (!verifyMockExists('claudeMocked')) {
9
+ await mock.setup();
10
+ }
11
+ }
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ // Mock dependencies
7
+ vi.mock('node:child_process', () => ({
8
+ spawn: vi.fn()
9
+ }));
10
+ vi.mock('node:fs');
11
+ vi.mock('node:os');
12
+ vi.mock('node:path', () => ({
13
+ resolve: vi.fn((path) => path),
14
+ join: vi.fn((...args) => args.join('/')),
15
+ isAbsolute: vi.fn((path) => path.startsWith('/'))
16
+ }));
17
+ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
18
+ Server: vi.fn().mockImplementation(function () {
19
+ this.setRequestHandler = vi.fn();
20
+ this.connect = vi.fn();
21
+ this.close = vi.fn();
22
+ this.onerror = undefined;
23
+ return this;
24
+ }),
25
+ }));
26
+ vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
27
+ ListToolsRequestSchema: { name: 'listTools' },
28
+ CallToolRequestSchema: { name: 'callTool' },
29
+ ErrorCode: {
30
+ InternalError: 'InternalError',
31
+ MethodNotFound: 'MethodNotFound',
32
+ InvalidParams: 'InvalidParams'
33
+ },
34
+ McpError: class McpError extends Error {
35
+ code;
36
+ constructor(code, message) {
37
+ super(message);
38
+ this.code = code;
39
+ this.name = 'McpError';
40
+ }
41
+ }
42
+ }));
43
+ const mockExistsSync = vi.mocked(existsSync);
44
+ const mockHomedir = vi.mocked(homedir);
45
+ describe('Argument Validation Tests', () => {
46
+ let consoleErrorSpy;
47
+ let errorHandler = null;
48
+ function setupServerMock() {
49
+ errorHandler = null;
50
+ vi.mocked(Server).mockImplementation(function () {
51
+ this.setRequestHandler = vi.fn();
52
+ this.connect = vi.fn();
53
+ this.close = vi.fn();
54
+ Object.defineProperty(this, 'onerror', {
55
+ get() { return errorHandler; },
56
+ set(handler) { errorHandler = handler; },
57
+ enumerable: true,
58
+ configurable: true
59
+ });
60
+ return this;
61
+ });
62
+ }
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ vi.resetModules();
66
+ vi.unmock('../server.js');
67
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
68
+ // Set up process.env
69
+ process.env = { ...process.env };
70
+ });
71
+ describe('Tool Arguments Schema', () => {
72
+ it('should validate valid arguments', async () => {
73
+ mockHomedir.mockReturnValue('/home/user');
74
+ mockExistsSync.mockReturnValue(true);
75
+ setupServerMock();
76
+ const module = await import('../server.js');
77
+ // @ts-ignore
78
+ const { ClaudeCodeServer } = module;
79
+ const server = new ClaudeCodeServer();
80
+ const mockServerInstance = vi.mocked(Server).mock.results[0].value;
81
+ // Find tool definition
82
+ const listToolsCall = mockServerInstance.setRequestHandler.mock.calls.find((call) => call[0].name === 'listTools');
83
+ const listHandler = listToolsCall[1];
84
+ const tools = await listHandler();
85
+ const claudeCodeTool = tools.tools[0];
86
+ // Extract schema from tool definition
87
+ const schema = z.object({
88
+ prompt: z.string(),
89
+ workFolder: z.string(),
90
+ model: z.string().optional(),
91
+ session_id: z.string().optional()
92
+ });
93
+ // Test valid cases
94
+ expect(() => schema.parse({ prompt: 'test', workFolder: '/tmp' })).not.toThrow();
95
+ expect(() => schema.parse({ prompt: 'test', workFolder: '/tmp', model: 'sonnet' })).not.toThrow();
96
+ });
97
+ it('should reject invalid arguments', async () => {
98
+ mockHomedir.mockReturnValue('/home/user');
99
+ mockExistsSync.mockReturnValue(true);
100
+ setupServerMock();
101
+ const module = await import('../server.js');
102
+ // @ts-ignore
103
+ const { ClaudeCodeServer } = module;
104
+ const server = new ClaudeCodeServer();
105
+ const mockServerInstance = vi.mocked(Server).mock.results[0].value;
106
+ // Find tool definition
107
+ const listToolsCall = mockServerInstance.setRequestHandler.mock.calls.find((call) => call[0].name === 'listTools');
108
+ const listHandler = listToolsCall[1];
109
+ const tools = await listHandler();
110
+ const claudeCodeTool = tools.tools[0];
111
+ // Extract schema from tool definition
112
+ const schema = z.object({
113
+ prompt: z.string(),
114
+ workFolder: z.string(),
115
+ model: z.string().optional(),
116
+ session_id: z.string().optional()
117
+ });
118
+ // Test invalid cases
119
+ expect(() => schema.parse({})).toThrow(); // Missing prompt and workFolder
120
+ expect(() => schema.parse({ prompt: 'test' })).toThrow(); // Missing workFolder
121
+ expect(() => schema.parse({ prompt: 123, workFolder: '/tmp' })).toThrow(); // Wrong prompt type
122
+ expect(() => schema.parse({ prompt: 'test', workFolder: 123 })).toThrow(); // Wrong workFolder type
123
+ });
124
+ it('should handle missing required fields', async () => {
125
+ const schema = z.object({
126
+ prompt: z.string(),
127
+ workFolder: z.string(),
128
+ model: z.string().optional(),
129
+ session_id: z.string().optional()
130
+ });
131
+ try {
132
+ schema.parse({});
133
+ }
134
+ catch (error) {
135
+ // Both prompt and workFolder are required
136
+ expect(error.errors.length).toBe(2);
137
+ expect(error.errors.some((e) => e.path[0] === 'prompt')).toBe(true);
138
+ expect(error.errors.some((e) => e.path[0] === 'workFolder')).toBe(true);
139
+ }
140
+ });
141
+ it('should allow optional fields to be undefined', async () => {
142
+ const schema = z.object({
143
+ prompt: z.string(),
144
+ workFolder: z.string(),
145
+ model: z.string().optional(),
146
+ session_id: z.string().optional()
147
+ });
148
+ const result = schema.parse({ prompt: 'test', workFolder: '/tmp' });
149
+ expect(result.model).toBeUndefined();
150
+ expect(result.session_id).toBeUndefined();
151
+ });
152
+ it('should handle extra fields gracefully', async () => {
153
+ const schema = z.object({
154
+ prompt: z.string(),
155
+ workFolder: z.string(),
156
+ model: z.string().optional(),
157
+ session_id: z.string().optional()
158
+ });
159
+ // By default, Zod strips unknown keys
160
+ const result = schema.parse({
161
+ prompt: 'test',
162
+ workFolder: '/tmp',
163
+ extraField: 'ignored'
164
+ });
165
+ expect(result).toEqual({ prompt: 'test', workFolder: '/tmp' });
166
+ expect(result).not.toHaveProperty('extraField');
167
+ });
168
+ });
169
+ describe('Runtime Argument Validation', () => {
170
+ let handlers;
171
+ let mockServerInstance;
172
+ async function setupServer() {
173
+ // Reset modules to ensure fresh import
174
+ vi.resetModules();
175
+ // Re-setup mocks after reset
176
+ const { existsSync } = await import('node:fs');
177
+ const { homedir } = await import('node:os');
178
+ vi.mocked(existsSync).mockReturnValue(true);
179
+ vi.mocked(homedir).mockReturnValue('/home/user');
180
+ const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
181
+ vi.mocked(Server).mockImplementation(() => {
182
+ mockServerInstance = {
183
+ setRequestHandler: vi.fn((schema, handler) => {
184
+ handlers.set(schema.name, handler);
185
+ }),
186
+ connect: vi.fn(),
187
+ close: vi.fn(),
188
+ onerror: undefined
189
+ };
190
+ return mockServerInstance;
191
+ });
192
+ const module = await import('../server.js');
193
+ // @ts-ignore
194
+ const { ClaudeCodeServer } = module;
195
+ const server = new ClaudeCodeServer();
196
+ return { server, handlers };
197
+ }
198
+ beforeEach(() => {
199
+ handlers = new Map();
200
+ // Re-setup mocks after vi.resetModules() in outer beforeEach
201
+ mockHomedir.mockReturnValue('/home/user');
202
+ mockExistsSync.mockReturnValue(true);
203
+ });
204
+ it('should validate workFolder is a string when provided', async () => {
205
+ mockHomedir.mockReturnValue('/home/user');
206
+ mockExistsSync.mockReturnValue(true);
207
+ await setupServer();
208
+ const handler = handlers.get('callTool');
209
+ // Test with non-string workFolder
210
+ await expect(handler({
211
+ params: {
212
+ name: 'run',
213
+ arguments: {
214
+ prompt: 'test',
215
+ workFolder: 123 // Invalid type
216
+ }
217
+ }
218
+ })).rejects.toThrow();
219
+ });
220
+ it('should reject empty string prompt', async () => {
221
+ await setupServer();
222
+ const handler = handlers.get('callTool');
223
+ // Empty string prompt should be rejected
224
+ await expect(handler({
225
+ params: {
226
+ name: 'run',
227
+ arguments: {
228
+ prompt: '', // Empty prompt
229
+ workFolder: '/tmp'
230
+ }
231
+ }
232
+ })).rejects.toThrow('Either prompt or prompt_file must be provided');
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { MCPTestClient } from './utils/mcp-client.js';
6
+ import { getSharedMock } from './utils/persistent-mock.js';
7
+ describe('Version Print on First Use', () => {
8
+ let client;
9
+ let testDir;
10
+ let consoleErrorSpy;
11
+ const serverPath = 'dist/server.js';
12
+ beforeEach(async () => {
13
+ // Ensure mock exists
14
+ await getSharedMock();
15
+ // Create a temporary directory for test files
16
+ testDir = mkdtempSync(join(tmpdir(), 'claude-code-test-'));
17
+ // Spy on console.error
18
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
19
+ // Initialize MCP client with custom binary name using absolute path
20
+ client = new MCPTestClient(serverPath, {
21
+ CLAUDE_CLI_NAME: '/tmp/claude-code-test-mock/claudeMocked',
22
+ });
23
+ await client.connect();
24
+ });
25
+ afterEach(async () => {
26
+ // Disconnect client
27
+ await client.disconnect();
28
+ // Clean up test directory
29
+ rmSync(testDir, { recursive: true, force: true });
30
+ // Restore console.error spy
31
+ consoleErrorSpy.mockRestore();
32
+ });
33
+ it('should print version and startup time only on first use', async () => {
34
+ // First tool call
35
+ await client.callTool('run', {
36
+ prompt: 'echo "test 1"',
37
+ workFolder: testDir,
38
+ });
39
+ // Find the version print in the console.error calls
40
+ const findVersionCall = (calls) => {
41
+ return calls.find(call => {
42
+ const str = call[1] || call[0]; // message might be first or second param
43
+ return typeof str === 'string' && str.includes('ai_cli_mcp v') && str.includes('started at');
44
+ });
45
+ };
46
+ // Check that version was printed on first use
47
+ const versionCall = findVersionCall(consoleErrorSpy.mock.calls);
48
+ expect(versionCall).toBeDefined();
49
+ expect(versionCall[1]).toMatch(/ai_cli_mcp v[0-9]+\.[0-9]+\.[0-9]+ started at \d{4}-\d{2}-\d{2}T/);
50
+ // Clear the spy but keep the spy active
51
+ consoleErrorSpy.mockClear();
52
+ // Second tool call
53
+ await client.callTool('run', {
54
+ prompt: 'echo "test 2"',
55
+ workFolder: testDir,
56
+ });
57
+ // Check that version was NOT printed on second use
58
+ const secondVersionCall = findVersionCall(consoleErrorSpy.mock.calls);
59
+ expect(secondVersionCall).toBeUndefined();
60
+ // Third tool call
61
+ await client.callTool('run', {
62
+ prompt: 'echo "test 3"',
63
+ workFolder: testDir,
64
+ });
65
+ // Should still not have been called with version print
66
+ const thirdVersionCall = findVersionCall(consoleErrorSpy.mock.calls);
67
+ expect(thirdVersionCall).toBeUndefined();
68
+ });
69
+ });