Skip to content

Commit f1c418a

Browse files
committed
feat: add MCP server entry point with tool registration
1 parent d6c973f commit f1c418a

File tree

2 files changed

+383
-0
lines changed

2 files changed

+383
-0
lines changed

src/index.test.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* @fileoverview Comprehensive tests for the MCP server entry point
3+
* @module index.test
4+
*/
5+
6+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10+
import * as cliModule from './cli.js';
11+
import { isCliMode, main, startMcpServer } from './index.js';
12+
import * as exampleModule from './tools/example.js';
13+
14+
// Mock external dependencies
15+
vi.mock('@modelcontextprotocol/sdk/server/index.js');
16+
vi.mock('@modelcontextprotocol/sdk/server/stdio.js');
17+
vi.mock('./cli.js');
18+
vi.mock('./tools/example.js');
19+
20+
/**
21+
* Test suite for the MCP server module
22+
*/
23+
describe('MCP Server Entry Point', () => {
24+
// biome-ignore lint/suspicious/noExplicitAny: Test mocks
25+
let mockServer: any;
26+
// biome-ignore lint/suspicious/noExplicitAny: Test mocks
27+
let mockTransport: any;
28+
// biome-ignore lint/suspicious/noExplicitAny: Test mocks
29+
let mockProgram: any;
30+
let originalArgv: string[];
31+
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
35+
// Store original argv
36+
originalArgv = [...process.argv];
37+
38+
// Mock Server
39+
mockServer = {
40+
setRequestHandler: vi.fn(),
41+
connect: vi.fn(),
42+
close: vi.fn(),
43+
};
44+
// biome-ignore lint/suspicious/noExplicitAny: Test mocks
45+
vi.mocked(Server).mockImplementation(() => mockServer as any);
46+
47+
// Mock Transport
48+
mockTransport = {};
49+
// biome-ignore lint/suspicious/noExplicitAny: Test mocks
50+
vi.mocked(StdioServerTransport).mockImplementation(() => mockTransport as any);
51+
52+
// Mock CLI
53+
mockProgram = {
54+
parseAsync: vi.fn(),
55+
};
56+
// biome-ignore lint/suspicious/noExplicitAny: Test mocks
57+
vi.mocked(cliModule.createCLI).mockReturnValue(mockProgram as any);
58+
59+
// Mock example tool
60+
vi.mocked(exampleModule.exampleTool).mockResolvedValue({
61+
content: [{ type: 'text', text: 'Test result' }],
62+
});
63+
});
64+
65+
afterEach(() => {
66+
// Restore original argv
67+
process.argv = originalArgv;
68+
});
69+
70+
/**
71+
* Test that the server module can be imported as an ES module
72+
*/
73+
it('should export as ES module', async () => {
74+
const module = await import('./index.js');
75+
expect(module).toBeDefined();
76+
});
77+
78+
/**
79+
* Test CLI mode detection
80+
*/
81+
it('should detect CLI mode when arguments are provided', () => {
82+
// Set CLI mode (more than 2 arguments)
83+
process.argv = ['node', 'index.js', 'example', 'test'];
84+
expect(isCliMode()).toBe(true);
85+
});
86+
87+
/**
88+
* Test MCP mode detection
89+
*/
90+
it('should detect MCP mode when no CLI arguments provided', () => {
91+
// Set MCP mode (only 2 arguments: node and script name)
92+
process.argv = ['node', 'index.js'];
93+
expect(isCliMode()).toBe(false);
94+
});
95+
96+
/**
97+
* Test main function in CLI mode
98+
*/
99+
it('should run CLI when in CLI mode', async () => {
100+
process.argv = ['node', 'index.js', 'example', 'test'];
101+
102+
await main();
103+
104+
expect(cliModule.createCLI).toHaveBeenCalled();
105+
expect(mockProgram.parseAsync).toHaveBeenCalledWith(process.argv);
106+
});
107+
108+
/**
109+
* Test main function in MCP mode
110+
*/
111+
it('should start MCP server when in MCP mode', async () => {
112+
process.argv = ['node', 'index.js'];
113+
114+
await main();
115+
116+
expect(Server).toHaveBeenCalledWith(
117+
{ name: 'mcp-template', version: '0.1.0' },
118+
{ capabilities: { tools: {} } },
119+
);
120+
expect(mockServer.connect).toHaveBeenCalledWith(mockTransport);
121+
});
122+
123+
/**
124+
* Test server creation and setup
125+
*/
126+
it('should create MCP server with correct configuration', async () => {
127+
await startMcpServer();
128+
129+
expect(Server).toHaveBeenCalledWith(
130+
{ name: 'mcp-template', version: '0.1.0' },
131+
{ capabilities: { tools: {} } },
132+
);
133+
expect(mockServer.connect).toHaveBeenCalledWith(mockTransport);
134+
});
135+
136+
/**
137+
* Test server request handlers registration
138+
*/
139+
it('should register ListTools and CallTool handlers', async () => {
140+
await startMcpServer();
141+
142+
// Should register 2 handlers: ListTools and CallTool
143+
expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2);
144+
145+
// Check that the correct schemas are used
146+
const calls = mockServer.setRequestHandler.mock.calls;
147+
expect(calls[0][0]).toBe(ListToolsRequestSchema);
148+
expect(calls[1][0]).toBe(CallToolRequestSchema);
149+
});
150+
151+
/**
152+
* Test ListTools handler
153+
*/
154+
it('should handle ListTools requests correctly', async () => {
155+
await startMcpServer();
156+
157+
// Get the ListTools handler
158+
const listToolsHandler = mockServer.setRequestHandler.mock.calls[0][1];
159+
160+
const result = await listToolsHandler();
161+
expect(result.tools).toHaveLength(3);
162+
expect(result.tools[0]).toEqual({
163+
name: 'example_tool',
164+
description: 'An example tool that echoes back the input',
165+
inputSchema: expect.any(Object),
166+
});
167+
expect(result.tools[1]).toEqual({
168+
name: 'fetch_example',
169+
description:
170+
'Demonstrate configurable fetch patterns with different backends and caching',
171+
inputSchema: expect.any(Object),
172+
});
173+
expect(result.tools[2]).toEqual({
174+
name: 'configure_fetch',
175+
description: 'Configure the global fetch instance settings and caching behavior',
176+
inputSchema: expect.any(Object),
177+
});
178+
});
179+
180+
/**
181+
* Test CallTool handler for example tool
182+
*/
183+
it('should handle CallTool requests for example_tool', async () => {
184+
await startMcpServer();
185+
186+
// Get the CallTool handler
187+
const callToolHandler = mockServer.setRequestHandler.mock.calls[1][1];
188+
189+
const request = {
190+
params: {
191+
name: 'example_tool',
192+
arguments: { message: 'test', uppercase: false },
193+
},
194+
};
195+
196+
const result = await callToolHandler(request);
197+
198+
expect(exampleModule.exampleTool).toHaveBeenCalledWith({
199+
message: 'test',
200+
uppercase: false,
201+
});
202+
expect(result).toEqual({
203+
content: [{ type: 'text', text: 'Test result' }],
204+
});
205+
});
206+
207+
/**
208+
* Test CallTool handler error for unknown tool
209+
*/
210+
it('should throw error for unknown tool in CallTool request', async () => {
211+
await startMcpServer();
212+
213+
// Get the CallTool handler
214+
const callToolHandler = mockServer.setRequestHandler.mock.calls[1][1];
215+
216+
const request = {
217+
params: {
218+
name: 'unknown_tool',
219+
arguments: {},
220+
},
221+
};
222+
223+
await expect(callToolHandler(request)).rejects.toThrow('Unknown tool: unknown_tool');
224+
});
225+
226+
/**
227+
* Test SIGINT handler registration
228+
*/
229+
it('should register SIGINT handler for graceful shutdown', async () => {
230+
const mockProcessOn = vi.spyOn(process, 'on').mockImplementation(() => process);
231+
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
232+
throw new Error('Process exit called');
233+
});
234+
235+
await startMcpServer();
236+
237+
// Find the SIGINT handler
238+
const sigintCall = mockProcessOn.mock.calls.find((call) => call[0] === 'SIGINT');
239+
expect(sigintCall).toBeDefined();
240+
241+
if (sigintCall) {
242+
const sigintHandler = sigintCall[1] as () => Promise<void>;
243+
244+
// Test the SIGINT handler
245+
try {
246+
await sigintHandler();
247+
} catch (error) {
248+
// Expected to throw due to process.exit mock
249+
}
250+
251+
expect(mockServer.close).toHaveBeenCalled();
252+
expect(mockProcessExit).toHaveBeenCalledWith(0);
253+
}
254+
255+
mockProcessOn.mockRestore();
256+
mockProcessExit.mockRestore();
257+
});
258+
});

src/index.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env node
2+
/**
3+
* @fileoverview Entry point that supports both MCP server and CLI modes
4+
* @module index
5+
*/
6+
7+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10+
import { zodToJsonSchema } from 'zod-to-json-schema';
11+
import { createCLI } from './cli.js';
12+
import { ExampleToolSchema, exampleTool } from './tools/example.js';
13+
import {
14+
ConfigureFetchSchema,
15+
FetchExampleSchema,
16+
configureFetchTool,
17+
fetchExampleTool,
18+
} from './tools/fetch-example.js';
19+
20+
/**
21+
* Determine if we're running in CLI mode
22+
* CLI mode is detected when command line arguments are provided beyond node and script name
23+
*/
24+
export function isCliMode() {
25+
return process.argv.length > 2;
26+
}
27+
28+
/**
29+
* Main entry point that handles both MCP and CLI modes
30+
*/
31+
export async function main() {
32+
if (isCliMode()) {
33+
// CLI Mode: Run as command-line tool
34+
const program = createCLI();
35+
await program.parseAsync(process.argv);
36+
} else {
37+
// MCP Mode: Run as MCP server
38+
await startMcpServer();
39+
}
40+
}
41+
42+
/**
43+
* Start the MCP server with all configured tools and handlers
44+
*/
45+
export async function startMcpServer() {
46+
/**
47+
* Create the MCP server instance with configured capabilities
48+
*/
49+
const server = new Server(
50+
{
51+
name: 'mcp-template',
52+
version: '0.1.0',
53+
},
54+
{
55+
capabilities: {
56+
tools: {},
57+
},
58+
},
59+
);
60+
61+
/**
62+
* Register handler for listing available tools
63+
* @returns List of available tools with their schemas
64+
*/
65+
server.setRequestHandler(ListToolsRequestSchema, async () => {
66+
return {
67+
tools: [
68+
{
69+
name: 'example_tool',
70+
description: 'An example tool that echoes back the input',
71+
inputSchema: zodToJsonSchema(ExampleToolSchema),
72+
},
73+
{
74+
name: 'fetch_example',
75+
description:
76+
'Demonstrate configurable fetch patterns with different backends and caching',
77+
inputSchema: zodToJsonSchema(FetchExampleSchema),
78+
},
79+
{
80+
name: 'configure_fetch',
81+
description:
82+
'Configure the global fetch instance settings and caching behavior',
83+
inputSchema: zodToJsonSchema(ConfigureFetchSchema),
84+
},
85+
],
86+
};
87+
});
88+
89+
/**
90+
* Register handler for executing tool calls
91+
* @param request - The tool call request containing tool name and arguments
92+
* @returns Tool execution result
93+
*/
94+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
95+
const { name, arguments: args } = request.params;
96+
97+
switch (name) {
98+
case 'example_tool':
99+
return await exampleTool(args);
100+
case 'fetch_example':
101+
return await fetchExampleTool(args);
102+
case 'configure_fetch':
103+
return await configureFetchTool(args);
104+
default:
105+
throw new Error(`Unknown tool: ${name}`);
106+
}
107+
});
108+
109+
/**
110+
* Start the MCP server using stdio transport
111+
*/
112+
const transport = new StdioServerTransport();
113+
await server.connect(transport);
114+
115+
/**
116+
* Handle graceful shutdown on SIGINT (Ctrl+C)
117+
*/
118+
process.on('SIGINT', async () => {
119+
await server.close();
120+
process.exit(0);
121+
});
122+
}
123+
124+
// Start the application
125+
main().catch(console.error);

0 commit comments

Comments
 (0)