Skip to content

Commit c749508

Browse files
authored
Merge pull request #1 from Mearman/add-cli-functionality
feat: add CLI functionality and configurable fetch pattern
2 parents 7a4ccfd + 337e819 commit c749508

File tree

11 files changed

+2148
-55
lines changed

11 files changed

+2148
-55
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"version": "1.0.0",
44
"description": "Template for building MCP (Model Context Protocol) servers with TypeScript",
55
"main": "dist/index.js",
6+
"bin": "dist/index.js",
67
"type": "module",
78
"scripts": {
89
"build": "tsc",
@@ -38,6 +39,10 @@
3839
],
3940
"dependencies": {
4041
"@modelcontextprotocol/sdk": "^1.12.1",
42+
"chalk": "^5.4.1",
43+
"commander": "^14.0.0",
44+
"node-fetch-cache": "^5.0.2",
45+
"ora": "^8.2.0",
4146
"zod": "^3.25.51",
4247
"zod-to-json-schema": "^3.24.1"
4348
},

src/cli.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { createCLI } from './cli.js';
3+
import * as exampleModule from './tools/example.js';
4+
import * as fetchExampleModule from './tools/fetch-example.js';
5+
6+
vi.mock('./tools/example.js');
7+
vi.mock('./tools/fetch-example.js');
8+
9+
describe('CLI', () => {
10+
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
11+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
16+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
17+
});
18+
19+
it('should create CLI program', () => {
20+
const program = createCLI();
21+
expect(program.name()).toBe('mcp-template');
22+
expect(program.description()).toContain('MCP template');
23+
});
24+
25+
it('should handle example command', async () => {
26+
vi.spyOn(exampleModule, 'exampleTool').mockResolvedValue({
27+
content: [
28+
{
29+
type: 'text',
30+
text: 'Echo: Hello World',
31+
},
32+
],
33+
});
34+
35+
const program = createCLI();
36+
await program.parseAsync(['node', 'cli', 'example', 'Hello World']);
37+
38+
expect(exampleModule.exampleTool).toHaveBeenCalledWith({
39+
message: 'Hello World',
40+
uppercase: false,
41+
});
42+
});
43+
44+
it('should handle example command with uppercase option', async () => {
45+
vi.spyOn(exampleModule, 'exampleTool').mockResolvedValue({
46+
content: [
47+
{
48+
type: 'text',
49+
text: 'Echo: HELLO WORLD',
50+
},
51+
],
52+
});
53+
54+
const program = createCLI();
55+
await program.parseAsync(['node', 'cli', 'example', 'Hello World', '--uppercase']);
56+
57+
expect(exampleModule.exampleTool).toHaveBeenCalledWith({
58+
message: 'Hello World',
59+
uppercase: true,
60+
});
61+
});
62+
63+
it('should handle errors gracefully', async () => {
64+
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
65+
throw new Error('Process exit called');
66+
});
67+
68+
vi.spyOn(exampleModule, 'exampleTool').mockRejectedValue(new Error('Test error'));
69+
70+
const program = createCLI();
71+
72+
try {
73+
await program.parseAsync(['node', 'cli', 'example', 'Hello World']);
74+
} catch (error) {
75+
// Expected to throw due to process.exit mock
76+
}
77+
78+
expect(mockProcessExit).toHaveBeenCalledWith(1);
79+
mockProcessExit.mockRestore();
80+
});
81+
82+
it('should handle fetch-example command', async () => {
83+
vi.spyOn(fetchExampleModule, 'fetchExampleTool').mockResolvedValue({
84+
content: [
85+
{
86+
type: 'text',
87+
text: '# Fetch Example Results\n\nURL: https://httpbin.org/json\nStatus: 200 OK',
88+
},
89+
],
90+
isError: false,
91+
});
92+
93+
const program = createCLI();
94+
await program.parseAsync(['node', 'cli', 'fetch-example', 'https://httpbin.org/json']);
95+
96+
expect(fetchExampleModule.fetchExampleTool).toHaveBeenCalledWith({
97+
url: 'https://httpbin.org/json',
98+
});
99+
});
100+
101+
it('should handle fetch-example command with options', async () => {
102+
vi.spyOn(fetchExampleModule, 'fetchExampleTool').mockResolvedValue({
103+
content: [
104+
{
105+
type: 'text',
106+
text: '# Fetch Example Results\n\nURL: https://httpbin.org/json\nBackend: cache-memory',
107+
},
108+
],
109+
isError: false,
110+
});
111+
112+
const program = createCLI();
113+
await program.parseAsync([
114+
'node',
115+
'cli',
116+
'fetch-example',
117+
'https://httpbin.org/json',
118+
'--backend',
119+
'cache-memory',
120+
'--no-cache',
121+
'--user-agent',
122+
'Test-Agent/1.0',
123+
]);
124+
125+
expect(fetchExampleModule.fetchExampleTool).toHaveBeenCalledWith({
126+
url: 'https://httpbin.org/json',
127+
backend: 'cache-memory',
128+
no_cache: true,
129+
user_agent: 'Test-Agent/1.0',
130+
});
131+
});
132+
133+
it('should handle configure-fetch command', async () => {
134+
vi.spyOn(fetchExampleModule, 'configureFetchTool').mockResolvedValue({
135+
content: [
136+
{
137+
type: 'text',
138+
text: '# Fetch Configuration Updated\n\nBackend: cache-disk\nCache TTL: 60000ms',
139+
},
140+
],
141+
isError: false,
142+
});
143+
144+
const program = createCLI();
145+
await program.parseAsync([
146+
'node',
147+
'cli',
148+
'configure-fetch',
149+
'--backend',
150+
'cache-disk',
151+
'--cache-ttl',
152+
'60000',
153+
'--clear-cache',
154+
]);
155+
156+
expect(fetchExampleModule.configureFetchTool).toHaveBeenCalledWith({
157+
backend: 'cache-disk',
158+
cache_ttl: 60000,
159+
clear_cache: true,
160+
});
161+
});
162+
163+
it('should handle fetch-example errors', async () => {
164+
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
165+
throw new Error('Process exit called');
166+
});
167+
168+
vi.spyOn(fetchExampleModule, 'fetchExampleTool').mockResolvedValue({
169+
content: [
170+
{
171+
type: 'text',
172+
text: 'Network error occurred',
173+
},
174+
],
175+
isError: true,
176+
});
177+
178+
const program = createCLI();
179+
180+
try {
181+
await program.parseAsync(['node', 'cli', 'fetch-example', 'https://invalid-url']);
182+
} catch (error) {
183+
// Expected to throw due to process.exit mock
184+
}
185+
186+
expect(mockProcessExit).toHaveBeenCalledWith(1);
187+
mockProcessExit.mockRestore();
188+
});
189+
});

src/cli.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @fileoverview Command-line interface for MCP template operations
3+
* @module cli
4+
*/
5+
6+
import chalk from 'chalk';
7+
import { Command } from 'commander';
8+
import ora from 'ora';
9+
import { exampleTool } from './tools/example.js';
10+
import { configureFetchTool, fetchExampleTool } from './tools/fetch-example.js';
11+
12+
/**
13+
* Create and configure the CLI command structure
14+
* @returns Configured Commander program instance
15+
* @description Sets up CLI commands for MCP template operations. This provides
16+
* command-line access to the same tools available via the MCP server interface.
17+
* @example
18+
* ```typescript
19+
* const program = createCLI();
20+
* await program.parseAsync(process.argv);
21+
* ```
22+
*/
23+
export function createCLI() {
24+
const program = new Command();
25+
26+
program
27+
.name('mcp-template')
28+
.description('CLI tool for MCP template operations')
29+
.version('1.0.0');
30+
31+
// Example tool command
32+
program
33+
.command('example <message>')
34+
.description('Run the example tool that echoes back the input')
35+
.option('-u, --uppercase', 'Convert the message to uppercase')
36+
.action(async (message: string, options: { uppercase?: boolean }) => {
37+
const spinner = ora('Running example tool...').start();
38+
try {
39+
const result = await exampleTool({
40+
message,
41+
uppercase: options.uppercase || false,
42+
});
43+
44+
spinner.succeed(chalk.green('Example tool completed!'));
45+
console.log(chalk.blue('Result:'), result.content[0].text);
46+
} catch (error) {
47+
spinner.fail(chalk.red('Error running example tool'));
48+
console.error(error);
49+
process.exit(1);
50+
}
51+
});
52+
53+
// Fetch example tool command
54+
program
55+
.command('fetch-example <url>')
56+
.description('Demonstrate configurable fetch patterns with different backends and caching')
57+
.option(
58+
'-b, --backend <backend>',
59+
'Fetch backend to use (built-in, cache-memory, cache-disk)',
60+
)
61+
.option('--no-cache', 'Bypass cache for this request')
62+
.option('-u, --user-agent <agent>', 'Custom User-Agent header for this request')
63+
.action(
64+
async (
65+
url: string,
66+
options: { backend?: string; cache?: boolean; userAgent?: string },
67+
) => {
68+
const spinner = ora('Fetching data...').start();
69+
try {
70+
// biome-ignore lint/suspicious/noExplicitAny: Building args dynamically
71+
const args: any = { url };
72+
if (options.backend) args.backend = options.backend;
73+
if (options.cache === false) args.no_cache = true;
74+
if (options.userAgent) args.user_agent = options.userAgent;
75+
76+
const result = await fetchExampleTool(args);
77+
78+
if (result.isError) {
79+
spinner.fail(chalk.red('Error fetching data'));
80+
console.error(chalk.red(result.content[0].text));
81+
process.exit(1);
82+
} else {
83+
spinner.succeed(chalk.green('Fetch completed!'));
84+
console.log(result.content[0].text);
85+
}
86+
} catch (error) {
87+
spinner.fail(chalk.red('Error fetching data'));
88+
console.error(error);
89+
process.exit(1);
90+
}
91+
},
92+
);
93+
94+
// Configure fetch tool command
95+
program
96+
.command('configure-fetch')
97+
.description('Configure the global fetch instance settings and caching behavior')
98+
.option('-b, --backend <backend>', 'Default fetch backend to use')
99+
.option('-t, --cache-ttl <ms>', 'Cache TTL in milliseconds', Number.parseInt)
100+
.option('-d, --cache-dir <dir>', 'Directory for disk caching')
101+
.option('-u, --user-agent <agent>', 'Default User-Agent header')
102+
.option('--clear-cache', 'Clear all caches')
103+
.action(
104+
async (options: {
105+
backend?: string;
106+
cacheTtl?: number;
107+
cacheDir?: string;
108+
userAgent?: string;
109+
clearCache?: boolean;
110+
}) => {
111+
const spinner = ora('Updating fetch configuration...').start();
112+
try {
113+
// biome-ignore lint/suspicious/noExplicitAny: Building args dynamically
114+
const args: any = {};
115+
if (options.backend) args.backend = options.backend;
116+
if (options.cacheTtl) args.cache_ttl = options.cacheTtl;
117+
if (options.cacheDir) args.cache_dir = options.cacheDir;
118+
if (options.userAgent) args.user_agent = options.userAgent;
119+
if (options.clearCache) args.clear_cache = true;
120+
121+
const result = await configureFetchTool(args);
122+
123+
if (result.isError) {
124+
spinner.fail(chalk.red('Error updating configuration'));
125+
console.error(chalk.red(result.content[0].text));
126+
process.exit(1);
127+
} else {
128+
spinner.succeed(chalk.green('Configuration updated!'));
129+
console.log(result.content[0].text);
130+
}
131+
} catch (error) {
132+
spinner.fail(chalk.red('Error updating configuration'));
133+
console.error(error);
134+
process.exit(1);
135+
}
136+
},
137+
);
138+
139+
return program;
140+
}

0 commit comments

Comments
 (0)