-
Notifications
You must be signed in to change notification settings - Fork 117
Expand file tree
/
Copy pathcli.test.ts
More file actions
384 lines (314 loc) · 11.9 KB
/
cli.test.ts
File metadata and controls
384 lines (314 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
/**
* Integration tests for CLI commands using the filesystem MCP server
*
* These tests spawn the actual CLI and test against a real MCP server.
* They require npx and @modelcontextprotocol/server-filesystem to be available.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { $ } from 'bun';
describe('CLI Integration Tests', () => {
let tempDir: string;
let configPath: string;
let testFilePath: string;
beforeAll(async () => {
// Create temp directory for test files
tempDir = await mkdtemp(join(tmpdir(), 'mcp-cli-integration-'));
// Create a test file to read
testFilePath = join(tempDir, 'test.txt');
await writeFile(testFilePath, 'Hello from test file!');
// Create subdirectory with more files
const subDir = join(tempDir, 'subdir');
await mkdir(subDir);
await writeFile(join(subDir, 'nested.txt'), 'Nested content');
// Create config pointing to the temp directory
// Note: npm_config_registry override ensures npx uses public npm registry
configPath = join(tempDir, 'mcp_servers.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
filesystem: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', tempDir],
env: {
npm_config_registry: 'https://registry.npmjs.org',
},
},
},
})
);
});
afterAll(async () => {
await rm(tempDir, { recursive: true, force: true });
});
// Helper to run CLI commands
async function runCli(
args: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts');
try {
// Disable daemon for tests for deterministic behavior
const result =
await $`MCP_NO_DAEMON=1 bun run ${cliPath} -c ${configPath} ${args}`.nothrow();
return {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
exitCode: result.exitCode,
};
} catch (error: any) {
return {
stdout: error.stdout?.toString() || '',
stderr: error.stderr?.toString() || '',
exitCode: error.exitCode || 1,
};
}
}
describe('--help', () => {
test('shows help message', async () => {
const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts');
const result = await $`bun run ${cliPath} --help`.nothrow();
expect(result.exitCode).toBe(0);
expect(result.stdout.toString()).toContain('mcp-cli');
expect(result.stdout.toString()).toContain('Usage:');
expect(result.stdout.toString()).toContain('Options:');
});
});
describe('--version', () => {
test('shows version', async () => {
const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts');
const result = await $`bun run ${cliPath} --version`.nothrow();
expect(result.exitCode).toBe(0);
expect(result.stdout.toString()).toMatch(/mcp-cli v\d+\.\d+\.\d+/);
});
});
describe('list command', () => {
test('lists servers and tools', async () => {
const result = await runCli([]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('filesystem');
// Should contain filesystem tools
expect(result.stdout).toMatch(/read_file|list_directory|write_file/);
});
test('lists with descriptions using -d flag', async () => {
const result = await runCli(['-d']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('filesystem');
// Descriptions should be present (checking for common patterns)
expect(result.stdout.length).toBeGreaterThan(100);
});
});
describe('grep command', () => {
test('searches tools by pattern', async () => {
const result = await runCli(['grep', '*file*']);
expect(result.exitCode).toBe(0);
// Should find file-related tools (space-separated format: server tool)
expect(result.stdout).toContain('read_file ');
});
test('searches with descriptions', async () => {
const result = await runCli(['grep', '*directory*', '-d']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('filesystem');
});
test('shows message for no matches', async () => {
const result = await runCli(['grep', '*nonexistent_xyz_123*']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('No tools found');
expect(result.stdout).toContain('Tip:');
});
});
describe('info command (server)', () => {
test('shows server details', async () => {
const result = await runCli(['info', 'filesystem']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Server:');
expect(result.stdout).toContain('filesystem');
expect(result.stdout).toContain('Transport:');
expect(result.stdout).toContain('Tools');
});
test('redacts stdio args from server details output', async () => {
const result = await runCli(['info', 'filesystem']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Command: npx [3 args hidden]');
expect(result.stdout).not.toContain('@modelcontextprotocol/server-filesystem');
expect(result.stdout).not.toContain(tempDir);
});
test('errors on unknown server', async () => {
const result = await runCli(['info', 'nonexistent_server']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('not found');
});
});
describe('info command (tool)', () => {
test('shows tool schema', async () => {
const result = await runCli(['info', 'filesystem', 'read_file']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Tool:');
expect(result.stdout).toContain('read_file');
expect(result.stdout).toContain('Server:');
expect(result.stdout).toContain('filesystem');
expect(result.stdout).toContain('Input Schema:');
});
test('errors on unknown tool', async () => {
const result = await runCli(['info', 'filesystem', 'nonexistent_tool']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('not found');
});
});
describe('call command', () => {
test('calls read_file tool', async () => {
const result = await runCli([
'call',
'filesystem',
'read_file',
JSON.stringify({ path: testFilePath }),
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Hello from test file!');
});
test('calls list_directory tool', async () => {
const result = await runCli([
'call',
'filesystem',
'list_directory',
JSON.stringify({ path: tempDir }),
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('test.txt');
expect(result.stdout).toContain('subdir');
});
test('handles tool errors gracefully', async () => {
const result = await runCli([
'call',
'filesystem',
'read_file',
JSON.stringify({ path: '/nonexistent/path/file.txt' }),
]);
// Server may return error as content or fail - verify error is reported
const output = result.stdout + result.stderr;
expect(output).toMatch(/denied|error|not found|outside|allowed/i);
});
test('handles invalid JSON arguments', async () => {
const result = await runCli(['call', 'filesystem', 'read_file', 'not valid json']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid JSON');
});
test('calls tool with no arguments', async () => {
// list_directory might work with default path
const result = await runCli(['call', 'filesystem', 'list_directory', '{}']);
// May succeed or fail depending on server implementation
// We just verify it doesn't crash
expect(typeof result.exitCode).toBe('number');
});
test('outputs raw text content, not MCP envelope (issue #25)', async () => {
// This test ensures the call command outputs raw text content
// instead of the full MCP protocol envelope like:
// { "content": [{ "type": "text", "text": "..." }] }
const result = await runCli([
'call',
'filesystem',
'read_file',
JSON.stringify({ path: testFilePath }),
]);
expect(result.exitCode).toBe(0);
// Output should be the raw file content
expect(result.stdout).toContain('Hello from test file!');
// Output should NOT contain MCP envelope structure
expect(result.stdout).not.toContain('"content"');
expect(result.stdout).not.toContain('"type"');
expect(result.stdout).not.toContain('"text"');
});
});
describe('error handling', () => {
test('handles missing config gracefully', async () => {
const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts');
const result =
await $`bun run ${cliPath} -c /nonexistent/config.json`.nothrow();
expect(result.exitCode).toBe(1);
expect(result.stderr.toString()).toContain('not found');
});
test('handles unknown options', async () => {
const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts');
const result = await $`bun run ${cliPath} --unknown-option`.nothrow();
expect(result.exitCode).toBe(1);
expect(result.stderr.toString()).toContain('Unknown option');
});
});
});
/**
* HTTP Transport Integration Tests
*
* These tests verify HTTP-based MCP server connectivity
* using the deepwiki.com public MCP server.
*/
describe('HTTP Transport Integration Tests', () => {
let tempDir: string;
let configPath: string;
beforeAll(async () => {
// Create temp directory for config
tempDir = await mkdtemp(join(tmpdir(), 'mcp-cli-http-test-'));
// Create config with HTTP-based MCP server
configPath = join(tempDir, 'mcp_servers.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
deepwiki: {
url: 'https://mcp.deepwiki.com/mcp',
},
},
})
);
});
afterAll(async () => {
await rm(tempDir, { recursive: true, force: true });
});
// Helper to run CLI commands with HTTP config
async function runCli(
args: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts');
try {
// Disable daemon for tests
const result =
await $`MCP_NO_DAEMON=1 bun run ${cliPath} -c ${configPath} ${args}`.nothrow();
return {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
exitCode: result.exitCode,
};
} catch (error: any) {
return {
stdout: error.stdout?.toString() || '',
stderr: error.stderr?.toString() || '',
exitCode: error.exitCode || 1,
};
}
}
describe('list command with HTTP server', () => {
test('lists HTTP server and its tools', async () => {
const result = await runCli([]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('deepwiki');
});
});
describe('info command with HTTP server', () => {
test('shows HTTP server details', async () => {
const result = await runCli(['info', 'deepwiki']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Server:');
expect(result.stdout).toContain('deepwiki');
expect(result.stdout).toContain('Transport:');
expect(result.stdout).toContain('HTTP');
});
});
describe('grep command with HTTP server', () => {
test('searches HTTP server tools', async () => {
const result = await runCli(['grep', '*']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('deepwiki');
});
});
});