diff --git a/README.md b/README.md index f37780d..46cb03f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The Model Context Protocol (MCP) is an open standard that enables AI assistants ## Prerequisites -- Node.js 18.0.0 or higher +- Node.js 20.0.0 or higher - npm (or another Node package manager) ## Installation @@ -74,7 +74,7 @@ These are the most relevant NPM scripts from package.json: ## Usage -The MCP server communicates over stdio and provides access to PatternFly documentation through the following tools. Both tools accept an argument named urlList which must be an array of strings. Each string is either: +The MCP server communicates over stdio and provides access to PatternFly documentation through the following tools. Both tools accept an argument named `urlList` which must be an array of strings. Each string is either: - An external URL (e.g., a raw GitHub URL to a .md file), or - A local file path (e.g., documentation/.../README.md). When running with the --docs-host flag, these paths are resolved under the llms-files directory instead. @@ -118,7 +118,7 @@ Then, passing a local path such as react-core/6.0.0/llms.txt in urlList will loa ## MCP client configuration examples -Most MCP clients use a JSON configuration that tells the client how to start this server. The server itself does not read that JSON; it only reads CLI flags and environment variables. Below are examples you can adapt to your MCP client. +Most MCP clients use a JSON configuration to specify how to start this server. The server itself only reads CLI flags and environment variables, not the JSON configuration. Below are examples you can adapt to your MCP client. ### Minimal client config (npx) @@ -197,30 +197,71 @@ npx @modelcontextprotocol/inspector-cli \ ## Environment variables - DOC_MCP_FETCH_TIMEOUT_MS: Milliseconds to wait before aborting an HTTP fetch (default: 15000) -- DOC_MCP_CLEAR_COOLDOWN_MS: Default cooldown value used in internal cache configuration. The current public API does not expose a clearCache tool. +- DOC_MCP_CLEAR_COOLDOWN_MS: Default cooldown value used in internal cache configuration. The current public API does not expose a `clearCache` tool. ## Programmatic usage (advanced) -The package provides programmatic access through the `start()` function (or `main()` as an alternative): +The package provides programmatic access through the `start()` function: ```typescript -import { start, main, type CliOptions } from '@patternfly/patternfly-mcp'; +import { start, main, type CliOptions, type ServerInstance } from '@patternfly/patternfly-mcp'; // Use with default options (equivalent to CLI without flags) -await start(); +const server = await start(); // Override CLI options programmatically -await start({ docsHost: true }); +const serverWithOptions = await start({ docsHost: true }); // Multiple options can be overridden -await start({ +const customServer = await start({ docsHost: true, // Future CLI options can be added here }); // TypeScript users can use the CliOptions type for type safety const options: Partial = { docsHost: true }; -await start(options); +const typedServer = await start(options); + +// Server instance provides shutdown control +console.log('Server running:', server.isRunning()); // true + +// Graceful shutdown +await server.stop(); +console.log('Server running:', server.isRunning()); // false +``` + +### ServerInstance Interface + +The `start()` function returns a `ServerInstance` object with the following methods: + +```typescript +interface ServerInstance { + /** + * Stop the server gracefully + */ + stop(): Promise; + + /** + * Check if server is running + */ + isRunning(): boolean; +} +``` + +**Usage Examples**: +```typescript +const server = await start(); + +// Check if server is running +if (server.isRunning()) { + console.log('Server is active'); +} + +// Graceful shutdown +await server.stop(); + +// Verify shutdown +console.log('Server running:', server.isRunning()); // false ``` ## Returned content details diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index 49353dc..b2d1a14 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -21,6 +21,69 @@ exports[`runServer should attempt to run server, create transport, connect, and }, ], ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], + "registerTool": [], +} +`; + +exports[`runServer should attempt to run server, disable SIGINT handler: console 1`] = ` +{ + "info": [], + "log": [ + [ + "PatternFly MCP server running on stdio", + ], + ], + "mcpServer": [ + [ + { + "name": "@patternfly/patternfly-mcp", + "version": "0.0.0", + }, + { + "capabilities": { + "tools": {}, + }, + }, + ], + ], + "process": [], + "registerTool": [], +} +`; + +exports[`runServer should attempt to run server, enable SIGINT handler explicitly: console 1`] = ` +{ + "info": [], + "log": [ + [ + "PatternFly MCP server running on stdio", + ], + ], + "mcpServer": [ + [ + { + "name": "@patternfly/patternfly-mcp", + "version": "0.0.0", + }, + { + "capabilities": { + "tools": {}, + }, + }, + ], + ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], "registerTool": [], } `; @@ -50,6 +113,12 @@ exports[`runServer should attempt to run server, register a tool: console 1`] = }, ], ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], "registerTool": [ [ "loremIpsum", @@ -91,6 +160,12 @@ exports[`runServer should attempt to run server, register multiple tools: consol }, ], ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], "registerTool": [ [ "loremIpsum", @@ -133,6 +208,12 @@ exports[`runServer should attempt to run server, use custom options: console 1`] }, ], ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], "registerTool": [], } `; @@ -165,6 +246,12 @@ exports[`runServer should attempt to run server, use default tools: console 1`] }, ], ], + "process": [ + [ + "SIGINT", + [Function], + ], + ], "registerTool": [ [ "usePatternFlyDocs", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 05c0627..e14666c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -26,7 +26,10 @@ describe('main', () => { // Setup default mocks mockParseCliOptions.mockReturnValue({ docsHost: false }); mockFreezeOptions.mockReturnValue({} as GlobalOptions); - mockRunServer.mockResolvedValue(undefined); + mockRunServer.mockResolvedValue({ + stop: jest.fn().mockResolvedValue(undefined), + isRunning: jest.fn().mockReturnValue(true) + }); }); afterEach(() => { @@ -105,6 +108,11 @@ describe('main', () => { mockRunServer.mockImplementation(async () => { callOrder.push('run'); + + return { + stop: jest.fn().mockResolvedValue(undefined), + isRunning: jest.fn().mockReturnValue(true) + }; }); await main(); @@ -161,7 +169,10 @@ describe('start alias', () => { // Setup default mocks mockParseCliOptions.mockReturnValue({ docsHost: false }); mockFreezeOptions.mockReturnValue({} as GlobalOptions); - mockRunServer.mockResolvedValue(undefined); + mockRunServer.mockResolvedValue({ + stop: jest.fn().mockResolvedValue(undefined), + isRunning: jest.fn().mockReturnValue(true) + }); }); afterEach(() => { diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index df93b8e..11c0196 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -16,6 +16,7 @@ describe('runServer', () => { let consoleInfoSpy: jest.SpyInstance; let consoleLogSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; + let processOnSpy: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); @@ -37,12 +38,16 @@ describe('runServer', () => { consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Spy on process.on method + processOnSpy = jest.spyOn(process, 'on').mockImplementation(); }); afterEach(() => { consoleInfoSpy.mockRestore(); consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); + processOnSpy.mockRestore(); }); it.each([ @@ -90,16 +95,34 @@ describe('runServer', () => { jest.fn() ]) ] + }, + { + description: 'disable SIGINT handler', + options: undefined, + tools: [], + enableSigint: false + }, + { + description: 'enable SIGINT handler explicitly', + options: undefined, + tools: [], + enableSigint: true } - ])('should attempt to run server, $description', async ({ options, tools }) => { - await runServer(options as GlobalOptions, (tools && { tools }) || undefined); + ])('should attempt to run server, $description', async ({ options, tools, enableSigint }) => { + const settings = { + ...(tools && { tools }), + ...(enableSigint !== undefined && { enableSigint }) + }; + + await runServer(options as GlobalOptions, Object.keys(settings).length > 0 ? settings : undefined); expect(MockStdioServerTransport).toHaveBeenCalled(); expect({ info: consoleInfoSpy.mock.calls, registerTool: mockServer.registerTool.mock.calls, mcpServer: MockMcpServer.mock.calls, - log: consoleLogSpy.mock.calls + log: consoleLogSpy.mock.calls, + process: processOnSpy.mock.calls }).toMatchSnapshot('console'); }); diff --git a/src/index.ts b/src/index.ts index e75c303..f513edd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,15 @@ #!/usr/bin/env node import { freezeOptions, parseCliOptions, type CliOptions } from './options'; -import { runServer } from './server'; +import { runServer, type ServerInstance } from './server'; /** * Main function - CLI entry point with optional programmatic overrides * * @param programmaticOptions - Optional programmatic options that override CLI options + * @returns {Promise} Server-instance with shutdown capability */ -const main = async (programmaticOptions?: Partial): Promise => { +const main = async (programmaticOptions?: Partial): Promise => { try { // Parse CLI options const cliOptions = parseCliOptions(); @@ -19,8 +20,8 @@ const main = async (programmaticOptions?: Partial): Promise => // Freeze options to prevent further changes freezeOptions(finalOptions); - // Create and run the server - await runServer(); + // Create and return server-instance + return await runServer(); } catch (error) { console.error('Failed to start server:', error); process.exit(1); @@ -35,4 +36,4 @@ if (process.env.NODE_ENV !== 'local') { }); } -export { main, main as start, type CliOptions }; +export { main, main as start, type CliOptions, type ServerInstance }; diff --git a/src/server.ts b/src/server.ts index a0d6245..16ff0e9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,20 +9,51 @@ type McpTool = [string, { description: string; inputSchema: any }, (args: any) = type McpToolCreator = () => McpTool; /** - * Create, register tool and errors, then run the server. + * Server instance with shutdown capability + */ +interface ServerInstance { + + /** + * Stop the server gracefully + */ + stop(): Promise; + + /** + * Check if server is running + */ + isRunning(): boolean; +} + +/** + * Create and run a server with shutdown, register tool and errors. * * @param options * @param settings * @param settings.tools + * @param settings.enableSigint */ const runServer = async (options = OPTIONS, { tools = [ usePatternFlyDocsTool, fetchDocsTool - ] -}: { tools?: McpToolCreator[] } = {}): Promise => { + ], + enableSigint = true +}: { tools?: McpToolCreator[]; enableSigint?: boolean } = {}): Promise => { + let server: McpServer | null = null; + let transport: StdioServerTransport | null = null; + let running = false; + + const stopServer = async () => { + if (server && running) { + await server?.close(); + running = false; + console.log('PatternFly MCP server stopped'); + process.exit(0); + } + }; + try { - const server = new McpServer( + server = new McpServer( { name: options.name, version: options.version @@ -38,26 +69,38 @@ const runServer = async (options = OPTIONS, { const [name, schema, callback] = toolCreator(); console.info(`Registered tool: ${name}`); - server.registerTool(name, schema, callback); + server?.registerTool(name, schema, callback); }); - process.on('SIGINT', async () => { - await server?.close(); - process.exit(0); - }); + if (enableSigint) { + process.on('SIGINT', async () => stopServer()); + } - const transport = new StdioServerTransport(); + transport = new StdioServerTransport(); await server.connect(transport); + + running = true; console.log('PatternFly MCP server running on stdio'); } catch (error) { console.error('Error creating MCP server:', error); throw error; } + + return { + async stop(): Promise { + return await stopServer(); + }, + + isRunning(): boolean { + return running; + } + }; }; export { runServer, type McpTool, - type McpToolCreator + type McpToolCreator, + type ServerInstance };