diff --git a/eslint.config.js b/eslint.config.js index c26f614..90f1802 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -65,6 +65,7 @@ export default [ { files: ['**/*.test.ts'], rules: { + '@typescript-eslint/no-explicit-any': 0, 'no-sparse-arrays': 0 } } diff --git a/src/__tests__/__snapshots__/docs.local.test.ts.snap b/src/__tests__/__snapshots__/docs.local.test.ts.snap index 5b071b3..d0d320c 100644 --- a/src/__tests__/__snapshots__/docs.local.test.ts.snap +++ b/src/__tests__/__snapshots__/docs.local.test.ts.snap @@ -2,15 +2,32 @@ exports[`docsLocal should return specific properties 1`] = ` { - "LOCAL_DOCS": [ - "[@patternfly/react-charts](/documentation/charts/README.md)", - "[@patternfly/react-chatbot](/documentation/chatbot/README.md)", - "[@patternfly/react-component-groups](/documentation/component-groups/README.md)", - "[@patternfly/react-components](/documentation/components/README.md)", - "[@patternfly/react-guidelines](/documentation/guidelines/README.md)", - "[@patternfly/react-resources](/documentation/resources/README.md)", - "[@patternfly/react-setup](/documentation/setup/README.md)", - "[@patternfly/react-troubleshooting](/documentation/troubleshooting/README.md)", - ], + "getLocalDocs": [Function], } `; + +exports[`getLocalDocs should return local references when called, default 1`] = ` +[ + "[@patternfly/react-charts](/documentation/charts/README.md)", + "[@patternfly/react-chatbot](/documentation/chatbot/README.md)", + "[@patternfly/react-component-groups](/documentation/component-groups/README.md)", + "[@patternfly/react-components](/documentation/components/README.md)", + "[@patternfly/react-guidelines](/documentation/guidelines/README.md)", + "[@patternfly/react-resources](/documentation/resources/README.md)", + "[@patternfly/react-setup](/documentation/setup/README.md)", + "[@patternfly/react-troubleshooting](/documentation/troubleshooting/README.md)", +] +`; + +exports[`getLocalDocs should return local references when called, with custom docsPath 1`] = ` +[ + "[@patternfly/react-charts](custom/docs/path/charts/README.md)", + "[@patternfly/react-chatbot](custom/docs/path/chatbot/README.md)", + "[@patternfly/react-component-groups](custom/docs/path/component-groups/README.md)", + "[@patternfly/react-components](custom/docs/path/components/README.md)", + "[@patternfly/react-guidelines](custom/docs/path/guidelines/README.md)", + "[@patternfly/react-resources](custom/docs/path/resources/README.md)", + "[@patternfly/react-setup](custom/docs/path/setup/README.md)", + "[@patternfly/react-troubleshooting](custom/docs/path/troubleshooting/README.md)", +] +`; diff --git a/src/__tests__/__snapshots__/index.test.ts.snap b/src/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..24fe62b --- /dev/null +++ b/src/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`main should merge default, cli and programmatic options, merge programmatic options with CLI options 1`] = ` +{ + "calls": [ + [ + { + "docsHost": true, + }, + ], + ], + "methodRegistersAs": "main", + "sequence": [ + "parse", + "set", + "run", + ], +} +`; + +exports[`main should merge default, cli and programmatic options, merge programmatic options with CLI options, with start alias 1`] = ` +{ + "calls": [ + [ + { + "docsHost": true, + }, + ], + ], + "methodRegistersAs": "main", + "sequence": [ + "parse", + "set", + "run", + ], +} +`; + +exports[`main should merge default, cli and programmatic options, with empty programmatic options 1`] = ` +{ + "calls": [ + [ + { + "docsHost": true, + }, + ], + ], + "methodRegistersAs": "main", + "sequence": [ + "parse", + "set", + "run", + ], +} +`; + +exports[`main should merge default, cli and programmatic options, with undefined programmatic options 1`] = ` +{ + "calls": [ + [ + { + "docsHost": false, + }, + ], + ], + "methodRegistersAs": "main", + "sequence": [ + "parse", + "set", + "run", + ], +} +`; diff --git a/src/__tests__/__snapshots__/options.context.test.ts.snap b/src/__tests__/__snapshots__/options.context.test.ts.snap new file mode 100644 index 0000000..cc8a4ec --- /dev/null +++ b/src/__tests__/__snapshots__/options.context.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`apply context options should set and get basic options, confirm by applying a potential property outside of typings 1`] = `"lorem = ipsum"`; + +exports[`apply context options should set and get basic options, default 1`] = `"docsHost = true"`; + +exports[`apply context options should set and get basic options, multiple property updates 1`] = `"name = ipsum"`; + +exports[`apply context options should set and get basic options, multiple property updates 2`] = `"name = dolor sit amet"`; + +exports[`apply context options should set and get basic options, multiple property updates 3`] = `"name = consectetur adipiscing elit"`; diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap new file mode 100644 index 0000000..928bc4c --- /dev/null +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`options defaults should return specific properties 1`] = ` +{ + "DEFAULTS": { + "DEFAULT_OPTIONS": { + "contextPath": "/", + "docsPath": "/documentation", + "llmsFilesPath": "/llms-files", + "name": "@patternfly/patternfly-mcp", + "pfExternal": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", + "pfExternalAccessibility": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", + "pfExternalCharts": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", + "pfExternalChartsComponents": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", + "pfExternalChartsDesign": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", + "pfExternalDesign": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", + "pfExternalDesignComponents": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", + "pfExternalDesignLayouts": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", + "repoName": "patternfly-mcp", + "resourceMemoOptions": { + "fetchUrl": { + "cacheErrors": false, + "cacheLimit": 100, + "expire": 180000, + }, + "readFile": { + "cacheErrors": false, + "cacheLimit": 50, + "expire": 120000, + }, + }, + "separator": " + +--- + +", + "toolMemoOptions": { + "fetchDocs": { + "cacheErrors": false, + "cacheLimit": 15, + "expire": 60000, + }, + "usePatternFlyDocs": { + "cacheErrors": false, + "cacheLimit": 10, + "expire": 60000, + }, + }, + "urlRegex": /\\^\\(https\\?:\\)\\\\/\\\\//i, + "version": "0.0.0", + }, + "DEFAULT_SEPARATOR": " + +--- + +", + "PF_EXTERNAL": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", + "PF_EXTERNAL_ACCESSIBILITY": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", + "PF_EXTERNAL_CHARTS": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", + "PF_EXTERNAL_CHARTS_COMPONENTS": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", + "PF_EXTERNAL_CHARTS_DESIGN": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", + "PF_EXTERNAL_DESIGN": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", + "PF_EXTERNAL_DESIGN_COMPONENTS": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", + "PF_EXTERNAL_DESIGN_LAYOUTS": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", + "RESOURCE_MEMO_OPTIONS": { + "fetchUrl": { + "cacheErrors": false, + "cacheLimit": 100, + "expire": 180000, + }, + "readFile": { + "cacheErrors": false, + "cacheLimit": 50, + "expire": 120000, + }, + }, + "TOOL_MEMO_OPTIONS": { + "fetchDocs": { + "cacheErrors": false, + "cacheLimit": 15, + "expire": 60000, + }, + "usePatternFlyDocs": { + "cacheErrors": false, + "cacheLimit": 10, + "expire": 60000, + }, + }, + "URL_REGEX": /\\^\\(https\\?:\\)\\\\/\\\\//i, + }, + "DEFAULT_OPTIONS": { + "contextPath": "/", + "docsPath": "/documentation", + "llmsFilesPath": "/llms-files", + "name": "@patternfly/patternfly-mcp", + "pfExternal": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", + "pfExternalAccessibility": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", + "pfExternalCharts": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", + "pfExternalChartsComponents": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", + "pfExternalChartsDesign": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", + "pfExternalDesign": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", + "pfExternalDesignComponents": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", + "pfExternalDesignLayouts": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", + "repoName": "patternfly-mcp", + "resourceMemoOptions": { + "fetchUrl": { + "cacheErrors": false, + "cacheLimit": 100, + "expire": 180000, + }, + "readFile": { + "cacheErrors": false, + "cacheLimit": 50, + "expire": 120000, + }, + }, + "separator": " + +--- + +", + "toolMemoOptions": { + "fetchDocs": { + "cacheErrors": false, + "cacheLimit": 15, + "expire": 60000, + }, + "usePatternFlyDocs": { + "cacheErrors": false, + "cacheLimit": 10, + "expire": 60000, + }, + }, + "urlRegex": /\\^\\(https\\?:\\)\\\\/\\\\//i, + "version": "0.0.0", + }, + "DEFAULT_SEPARATOR": " + +--- + +", + "PF_EXTERNAL": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", + "PF_EXTERNAL_ACCESSIBILITY": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", + "PF_EXTERNAL_CHARTS": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", + "PF_EXTERNAL_CHARTS_COMPONENTS": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", + "PF_EXTERNAL_CHARTS_DESIGN": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", + "PF_EXTERNAL_DESIGN": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", + "PF_EXTERNAL_DESIGN_COMPONENTS": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", + "PF_EXTERNAL_DESIGN_LAYOUTS": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", + "RESOURCE_MEMO_OPTIONS": { + "fetchUrl": { + "cacheErrors": false, + "cacheLimit": 100, + "expire": 180000, + }, + "readFile": { + "cacheErrors": false, + "cacheLimit": 50, + "expire": 120000, + }, + }, + "TOOL_MEMO_OPTIONS": { + "fetchDocs": { + "cacheErrors": false, + "cacheLimit": 15, + "expire": 60000, + }, + "usePatternFlyDocs": { + "cacheErrors": false, + "cacheLimit": 10, + "expire": 60000, + }, + }, + "URL_REGEX": /\\^\\(https\\?:\\)\\\\/\\\\//i, +} +`; diff --git a/src/__tests__/__snapshots__/options.test.ts.snap b/src/__tests__/__snapshots__/options.test.ts.snap index a2f7afd..c0d8cde 100644 --- a/src/__tests__/__snapshots__/options.test.ts.snap +++ b/src/__tests__/__snapshots__/options.test.ts.snap @@ -1,146 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`freezeOptions should return frozen options with consistent properties: frozen 1`] = ` -{ - "contextPath": "/", - "docsHost": true, - "docsPath": "/documentation", - "llmsFilesPath": "/llms-files", - "name": "@patternfly/patternfly-mcp", - "pfExternal": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", - "pfExternalAccessibility": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", - "pfExternalCharts": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", - "pfExternalChartsComponents": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", - "pfExternalChartsDesign": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", - "pfExternalDesign": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", - "pfExternalDesignComponents": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", - "pfExternalDesignLayouts": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", - "repoName": "patternfly-mcp", - "resourceMemoOptions": { - "fetchUrl": { - "cacheErrors": false, - "cacheLimit": 100, - "expire": 180000, - }, - "readFile": { - "cacheErrors": false, - "cacheLimit": 50, - "expire": 120000, - }, - }, - "separator": " - ---- - -", - "toolMemoOptions": { - "fetchDocs": { - "cacheErrors": false, - "cacheLimit": 15, - "expire": 60000, - }, - "usePatternFlyDocs": { - "cacheErrors": false, - "cacheLimit": 10, - "expire": 60000, - }, - }, - "urlRegex": /\\^\\(https\\?:\\)\\\\/\\\\//i, - "version": "0.0.0", -} -`; - -exports[`options should return specific properties 1`] = ` -{ - "DEFAULT_SEPARATOR": " - ---- - -", - "OPTIONS": { - "contextPath": "/", - "docsPath": "/documentation", - "llmsFilesPath": "/llms-files", - "name": "@patternfly/patternfly-mcp", - "pfExternal": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", - "pfExternalAccessibility": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", - "pfExternalCharts": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", - "pfExternalChartsComponents": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", - "pfExternalChartsDesign": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", - "pfExternalDesign": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", - "pfExternalDesignComponents": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", - "pfExternalDesignLayouts": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", - "repoName": "patternfly-mcp", - "resourceMemoOptions": { - "fetchUrl": { - "cacheErrors": false, - "cacheLimit": 100, - "expire": 180000, - }, - "readFile": { - "cacheErrors": false, - "cacheLimit": 50, - "expire": 120000, - }, - }, - "separator": " - ---- - -", - "toolMemoOptions": { - "fetchDocs": { - "cacheErrors": false, - "cacheLimit": 15, - "expire": 60000, - }, - "usePatternFlyDocs": { - "cacheErrors": false, - "cacheLimit": 10, - "expire": 60000, - }, - }, - "urlRegex": /\\^\\(https\\?:\\)\\\\/\\\\//i, - "version": "0.0.0", - }, - "PF_EXTERNAL": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content", - "PF_EXTERNAL_ACCESSIBILITY": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/accessibility", - "PF_EXTERNAL_CHARTS": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src", - "PF_EXTERNAL_CHARTS_COMPONENTS": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/victory/components", - "PF_EXTERNAL_CHARTS_DESIGN": "https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src/charts", - "PF_EXTERNAL_DESIGN": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines", - "PF_EXTERNAL_DESIGN_COMPONENTS": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/components", - "PF_EXTERNAL_DESIGN_LAYOUTS": "https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/design-guidelines/layouts", - "RESOURCE_MEMO_OPTIONS": { - "fetchUrl": { - "cacheErrors": false, - "cacheLimit": 100, - "expire": 180000, - }, - "readFile": { - "cacheErrors": false, - "cacheLimit": 50, - "expire": 120000, - }, - }, - "TOOL_MEMO_OPTIONS": { - "fetchDocs": { - "cacheErrors": false, - "cacheLimit": 15, - "expire": 60000, - }, - "usePatternFlyDocs": { - "cacheErrors": false, - "cacheLimit": 10, - "expire": 60000, - }, - }, - "URL_REGEX": /\\^\\(https\\?:\\)\\\\/\\\\//i, - "freezeOptions": [Function], - "parseCliOptions": [Function], -} -`; - exports[`parseCliOptions should attempt to parse args with --docs-host flag 1`] = ` { "docsHost": true, diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index be65c00..45ab916 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -126,7 +126,7 @@ exports[`runServer should attempt to run server, register a tool: console 1`] = "description": "Lorem Ipsum", "inputSchema": {}, }, - [MockFunction], + [Function], ], ], } @@ -173,7 +173,7 @@ exports[`runServer should attempt to run server, register multiple tools: consol "description": "Lorem Ipsum", "inputSchema": {}, }, - [MockFunction], + [Function], ], [ "dolorSit", @@ -181,7 +181,7 @@ exports[`runServer should attempt to run server, register multiple tools: consol "description": "Dolor Sit", "inputSchema": {}, }, - [MockFunction], + [Function], ], ], } diff --git a/src/__tests__/docs.local.test.ts b/src/__tests__/docs.local.test.ts index a0dc793..bba032b 100644 --- a/src/__tests__/docs.local.test.ts +++ b/src/__tests__/docs.local.test.ts @@ -1,4 +1,6 @@ import * as docsLocal from '../docs.local'; +import { getLocalDocs } from '../docs.local'; +import { type GlobalOptions } from '../options'; describe('docsLocal', () => { it('should return specific properties', () => { @@ -6,3 +8,18 @@ describe('docsLocal', () => { }); }); +describe('getLocalDocs', () => { + it.each([ + { + description: 'default', + options: undefined + }, + { + description: 'with custom docsPath', + options: { docsPath: 'custom/docs/path' } + } + ])('should return local references when called, $description', ({ options }) => { + expect(getLocalDocs(options as GlobalOptions)).toMatchSnapshot(); + }); +}); + diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index e14666c..39ad80f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,21 +1,26 @@ import { main, start, type CliOptions } from '../index'; -import { parseCliOptions, freezeOptions, type GlobalOptions } from '../options'; +import { parseCliOptions, type GlobalOptions } from '../options'; +import { DEFAULT_OPTIONS } from '../options.defaults'; +import { setOptions } from '../options.context'; import { runServer } from '../server'; // Mock dependencies jest.mock('../options'); +jest.mock('../options.context'); jest.mock('../server'); const mockParseCliOptions = parseCliOptions as jest.MockedFunction; -const mockFreezeOptions = freezeOptions as jest.MockedFunction; +const mockSetOptions = setOptions as jest.MockedFunction; const mockRunServer = runServer as jest.MockedFunction; describe('main', () => { let consoleErrorSpy: jest.SpyInstance; let processExitSpy: jest.SpyInstance; + let callOrder: string[] = []; beforeEach(() => { jest.clearAllMocks(); + callOrder = []; // Mock process.exit to prevent actual exit processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); @@ -24,88 +29,16 @@ describe('main', () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); // Setup default mocks - mockParseCliOptions.mockReturnValue({ docsHost: false }); - mockFreezeOptions.mockReturnValue({} as GlobalOptions); - mockRunServer.mockResolvedValue({ - stop: jest.fn().mockResolvedValue(undefined), - isRunning: jest.fn().mockReturnValue(true) - }); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - processExitSpy.mockRestore(); - }); - - it('should attempt to freeze options with parsed CLI options', async () => { - const cliOptions = { docsHost: true }; - - mockParseCliOptions.mockReturnValue(cliOptions); - - await main(); - - expect(mockFreezeOptions).toHaveBeenCalledWith(cliOptions); - }); - - it('should attempt to parse CLI options and run the server', async () => { - await main(); - - expect(mockParseCliOptions).toHaveBeenCalled(); - expect(mockRunServer).toHaveBeenCalled(); - }); - - it('should handle server startup errors', async () => { - const error = new Error('Server failed to start'); - - mockRunServer.mockRejectedValue(error); - - await main(); - - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to start server:', error); - expect(processExitSpy).toHaveBeenCalledWith(1); - }); - - it('should handle parseCliOptions errors', async () => { - const error = new Error('Failed to parse CLI options'); - - mockParseCliOptions.mockImplementation(() => { - throw error; - }); - - await main(); - - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to start server:', error); - expect(processExitSpy).toHaveBeenCalledWith(1); - }); - - it('should handle freezeOptions errors', async () => { - const error = new Error('Failed to freeze options'); - - mockFreezeOptions.mockImplementation(() => { - throw error; - }); - - await main(); - - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to start server:', error); - expect(processExitSpy).toHaveBeenCalledWith(1); - }); - - it('should execute steps in correct order', async () => { - const callOrder: string[] = []; - mockParseCliOptions.mockImplementation(() => { callOrder.push('parse'); return { docsHost: false }; }); + mockSetOptions.mockImplementation(options => { + callOrder.push('set'); - mockFreezeOptions.mockImplementation(() => { - callOrder.push('freeze'); - - return {} as GlobalOptions; + return Object.freeze({ ...DEFAULT_OPTIONS, ...options }) as unknown as GlobalOptions; }); - mockRunServer.mockImplementation(async () => { callOrder.push('run'); @@ -114,100 +47,105 @@ describe('main', () => { isRunning: jest.fn().mockReturnValue(true) }; }); - - await main(); - - expect(callOrder).toEqual(['parse', 'freeze', 'run']); }); - it('should merge programmatic options with CLI options', async () => { - const cliOptions = { docsHost: false }; - const programmaticOptions = { docsHost: true }; - - mockParseCliOptions.mockReturnValue(cliOptions); - - await main(programmaticOptions); - - // Should merge CLI options with programmatic options (programmatic takes precedence) - expect(mockFreezeOptions).toHaveBeenCalledWith({ docsHost: true }); - }); - - it('should work with empty programmatic options', async () => { - const cliOptions = { docsHost: true }; - - mockParseCliOptions.mockReturnValue(cliOptions); - - await main({}); - - expect(mockFreezeOptions).toHaveBeenCalledWith({ docsHost: true }); + afterEach(() => { + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); }); - it('should work with undefined programmatic options', async () => { - const cliOptions = { docsHost: false }; + it('should handle server startup errors', async () => { + const error = new Error('Server failed to start'); - mockParseCliOptions.mockReturnValue(cliOptions); + mockRunServer.mockRejectedValue(error); await main(); - expect(mockFreezeOptions).toHaveBeenCalledWith({ docsHost: false }); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to start server:', error); + expect(processExitSpy).toHaveBeenCalledWith(1); }); -}); -describe('start alias', () => { - let consoleErrorSpy: jest.SpyInstance; - let processExitSpy: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock process.exit to prevent actual exit - processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); - - // Mock console.error - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - // Setup default mocks - mockParseCliOptions.mockReturnValue({ docsHost: false }); - mockFreezeOptions.mockReturnValue({} as GlobalOptions); - mockRunServer.mockResolvedValue({ - stop: jest.fn().mockResolvedValue(undefined), - isRunning: jest.fn().mockReturnValue(true) + it.each([ + { + description: 'parseCliOptions', + error: new Error('Failed to parse CLI options'), + message: 'Failed to start server:', + method: main + }, + { + description: 'setOptions', + error: new Error('Failed to set options'), + message: 'Failed to start server:', + method: main + }, + { + description: 'parseCliOptions, with start alias', + error: new Error('Failed to parse CLI options'), + message: 'Failed to start server:', + method: start + }, + { + description: 'setOptions, with start alias', + error: new Error('Failed to set options'), + message: 'Failed to start server:', + method: start + } + ])('should handle errors, $description', async ({ error, message, method }) => { + mockSetOptions.mockImplementation(() => { + throw error; }); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - processExitSpy.mockRestore(); - }); - it('should be equivalent to main function', async () => { - const cliOptions = { docsHost: true }; + await method(); - mockParseCliOptions.mockReturnValue(cliOptions); - - await start(); - - expect(mockParseCliOptions).toHaveBeenCalled(); - expect(mockFreezeOptions).toHaveBeenCalledWith(cliOptions); - expect(mockRunServer).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith(message, error); + expect(processExitSpy).toHaveBeenCalledWith(1); }); - it('should accept programmatic options like main', async () => { - const cliOptions = { docsHost: false }; - const programmaticOptions = { docsHost: true }; + it.each([ + { + description: 'merge programmatic options with CLI options', + programmaticOptions: { docsHost: true }, + cliOptions: { docsHost: false }, + method: main + }, + { + description: 'with empty programmatic options', + programmaticOptions: {}, + cliOptions: { docsHost: true }, + method: main + }, + { + description: 'with undefined programmatic options', + programmaticOptions: undefined, + cliOptions: { docsHost: false }, + method: main + }, + { + description: 'merge programmatic options with CLI options, with start alias', + programmaticOptions: { docsHost: true }, + cliOptions: { docsHost: false }, + method: start + } + ])('should merge default, cli and programmatic options, $description', async ({ programmaticOptions, cliOptions, method }) => { + mockParseCliOptions.mockImplementation(() => { + callOrder.push('parse'); - mockParseCliOptions.mockReturnValue(cliOptions); + return cliOptions; + }); - await start(programmaticOptions); + await method(programmaticOptions); - expect(mockFreezeOptions).toHaveBeenCalledWith({ docsHost: true }); + expect({ + methodRegistersAs: method.name, + sequence: callOrder, + calls: mockSetOptions.mock.calls + }).toMatchSnapshot(); }); }); describe('type exports', () => { it('should export CliOptions type', () => { - // This test ensures the type is properly exported - // TypeScript compilation will fail if the type is not available + // TypeScript compilation will fail if the type is unavailable const options: Partial = { docsHost: true }; expect(options).toBeDefined(); diff --git a/src/__tests__/options.context.test.ts b/src/__tests__/options.context.test.ts new file mode 100644 index 0000000..38f6401 --- /dev/null +++ b/src/__tests__/options.context.test.ts @@ -0,0 +1,108 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { runServer, type McpTool } from '../server'; +import { getOptions, setOptions } from '../options.context'; + +// Mock dependencies +jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); +jest.mock('@modelcontextprotocol/sdk/server/stdio.js'); + +const MockMcpServer = McpServer as jest.MockedClass; +const MockStdioServerTransport = StdioServerTransport as jest.MockedClass; + +describe('apply context options', () => { + it.each([ + { + description: 'default', + options: [{ docsHost: true }], + findProperty: 'docsHost' + }, + { + description: 'confirm by applying a potential property outside of typings', + options: [{ lorem: 'ipsum' }], + findProperty: 'lorem' + }, + { + description: 'multiple property updates', + options: [{ name: 'ipsum' }, { name: 'dolor sit amet' }, { name: 'consectetur adipiscing elit' }], + findProperty: 'name' + } + ])('should set and get basic options, $description', ({ options, findProperty }) => { + options.forEach(opts => { + const setOpts = setOptions(opts as any); + const getOpts = getOptions(); + + expect(Object.isFrozen(setOpts)).toBe(true); + expect(Object.isFrozen(getOpts)).toBe(true); + expect(setOpts).toEqual(getOpts); + + expect(`${findProperty} = ${(getOpts as any)[findProperty as any]}`).toMatchSnapshot(); + }); + }); +}); + +describe('tool creator options context', () => { + let mockServer: any; + let mockTransport: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock server instance + mockServer = { + registerTool: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined) + }; + + // Mock transport instance + mockTransport = {}; + + MockMcpServer.mockImplementation(() => mockServer); + MockStdioServerTransport.mockImplementation(() => mockTransport); + }); + + it('should maintain equivalent option values inside tool callback', async () => { + setOptions({ name: 'als-contract-test', docsHost: true }); + + const tool = (options = getOptions()): McpTool => { + const callback = async () => { + const ctxOptions = getOptions(); + + const result = { + creator: { name: options.name, docsHost: options.docsHost }, + ctx: { name: ctxOptions.name, docsHost: ctxOptions.docsHost }, + isSameReference: Object.is(options, ctxOptions) + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result) + } + ] + }; + }; + + return [ + 'alsContract', + { description: 'Context test tool', inputSchema: {} }, + callback + ]; + }; + + await runServer(undefined, { tools: [tool], enableSigint: false }); + + // Extract the registered callback wrapper (which applies runWithOptions) + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + const [[_name, _schema, registeredCallback]] = mockServer.registerTool.mock.calls as any[]; + + const response = await registeredCallback({}); + const payload = JSON.parse(response?.content?.[0]?.text || '{}'); + + // Deep equality on selected properties and confirm references are unique + expect(payload.creator).toEqual(payload.ctx); + expect(payload.isSameReference).toBe(false); + }); +}); diff --git a/src/__tests__/options.defaults.test.ts b/src/__tests__/options.defaults.test.ts new file mode 100644 index 0000000..a31464d --- /dev/null +++ b/src/__tests__/options.defaults.test.ts @@ -0,0 +1,7 @@ +import * as options from '../options.defaults'; + +describe('options defaults', () => { + it('should return specific properties', () => { + expect(options).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts index 842fbb4..5ff80a8 100644 --- a/src/__tests__/options.test.ts +++ b/src/__tests__/options.test.ts @@ -1,11 +1,4 @@ -import * as options from '../options'; -import { parseCliOptions, freezeOptions, OPTIONS } from '../options'; - -describe('options', () => { - it('should return specific properties', () => { - expect(options).toMatchSnapshot(); - }); -}); +import { parseCliOptions } from '../options'; describe('parseCliOptions', () => { const originalArgv = process.argv; @@ -35,13 +28,3 @@ describe('parseCliOptions', () => { expect(result).toMatchSnapshot(); }); }); - -describe('freezeOptions', () => { - it('should return frozen options with consistent properties', () => { - const result = freezeOptions({ docsHost: true }); - - expect(Object.isFrozen(result)).toBe(true); - expect(result).toBe(OPTIONS); - expect(result).toMatchSnapshot('frozen'); - }); -}); diff --git a/src/docs.chart.ts b/src/docs.chart.ts index 03d2daa..07aedd2 100644 --- a/src/docs.chart.ts +++ b/src/docs.chart.ts @@ -1,4 +1,4 @@ -import { PF_EXTERNAL_CHARTS_COMPONENTS, PF_EXTERNAL_CHARTS_DESIGN } from './options'; +import { PF_EXTERNAL_CHARTS_COMPONENTS, PF_EXTERNAL_CHARTS_DESIGN } from './options.defaults'; const CHART_DOCS = [ `[@patternfly/Charts - Colors for Charts](${PF_EXTERNAL_CHARTS_COMPONENTS}/ChartTheme/examples/ChartTheme.md)`, diff --git a/src/docs.component.ts b/src/docs.component.ts index 8d66482..d36f325 100644 --- a/src/docs.component.ts +++ b/src/docs.component.ts @@ -1,4 +1,4 @@ -import { PF_EXTERNAL_ACCESSIBILITY, PF_EXTERNAL_DESIGN_COMPONENTS } from './options'; +import { PF_EXTERNAL_ACCESSIBILITY, PF_EXTERNAL_DESIGN_COMPONENTS } from './options.defaults'; const COMPONENT_DOCS = [ `[@patternfly/AboutModal - Design Guidelines](${PF_EXTERNAL_DESIGN_COMPONENTS}/about-modal/about-modal.md)`, diff --git a/src/docs.layout.ts b/src/docs.layout.ts index 23f6ff1..374c7ca 100644 --- a/src/docs.layout.ts +++ b/src/docs.layout.ts @@ -1,4 +1,4 @@ -import { PF_EXTERNAL_DESIGN_LAYOUTS } from './options'; +import { PF_EXTERNAL_DESIGN_LAYOUTS } from './options.defaults'; const LAYOUT_DOCS = [ `[@patternfly/Bullseye - Design Guidelines](${PF_EXTERNAL_DESIGN_LAYOUTS}/bullseye.md)`, diff --git a/src/docs.local.ts b/src/docs.local.ts index c0378b5..0afcca1 100644 --- a/src/docs.local.ts +++ b/src/docs.local.ts @@ -1,15 +1,20 @@ import { join } from 'node:path'; -import { OPTIONS } from './options'; +import { getOptions } from './options.context'; -const LOCAL_DOCS = [ - `[@patternfly/react-charts](${join(OPTIONS.docsPath, 'charts', 'README.md')})`, - `[@patternfly/react-chatbot](${join(OPTIONS.docsPath, 'chatbot', 'README.md')})`, - `[@patternfly/react-component-groups](${join(OPTIONS.docsPath, 'component-groups', 'README.md')})`, - `[@patternfly/react-components](${join(OPTIONS.docsPath, 'components', 'README.md')})`, - `[@patternfly/react-guidelines](${join(OPTIONS.docsPath, 'guidelines', 'README.md')})`, - `[@patternfly/react-resources](${join(OPTIONS.docsPath, 'resources', 'README.md')})`, - `[@patternfly/react-setup](${join(OPTIONS.docsPath, 'setup', 'README.md')})`, - `[@patternfly/react-troubleshooting](${join(OPTIONS.docsPath, 'troubleshooting', 'README.md')})` +/** + * Get local documentation paths + * + * @param options + */ +const getLocalDocs = (options = getOptions()) => [ + `[@patternfly/react-charts](${join(options.docsPath, 'charts', 'README.md')})`, + `[@patternfly/react-chatbot](${join(options.docsPath, 'chatbot', 'README.md')})`, + `[@patternfly/react-component-groups](${join(options.docsPath, 'component-groups', 'README.md')})`, + `[@patternfly/react-components](${join(options.docsPath, 'components', 'README.md')})`, + `[@patternfly/react-guidelines](${join(options.docsPath, 'guidelines', 'README.md')})`, + `[@patternfly/react-resources](${join(options.docsPath, 'resources', 'README.md')})`, + `[@patternfly/react-setup](${join(options.docsPath, 'setup', 'README.md')})`, + `[@patternfly/react-troubleshooting](${join(options.docsPath, 'troubleshooting', 'README.md')})` ]; -export { LOCAL_DOCS }; +export { getLocalDocs }; diff --git a/src/index.ts b/src/index.ts index f513edd..ab202a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { freezeOptions, parseCliOptions, type CliOptions } from './options'; +import { parseCliOptions, type CliOptions } from './options'; +import { setOptions } from './options.context'; import { runServer, type ServerInstance } from './server'; /** @@ -14,13 +15,9 @@ const main = async (programmaticOptions?: Partial): Promise(); + +/** + * Set and freeze cloned options in the current async context. + * + * @param {Partial} options - Options to set in context (merged with DEFAULT_OPTIONS) + * @returns {GlobalOptions} Cloned frozen options object + */ +const setOptions = (options: Partial): GlobalOptions => { + const merged = { ...DEFAULT_OPTIONS, ...options } as GlobalOptions; + const frozen = Object.freeze(structuredClone(merged)); + + optionsContext.enterWith(frozen); + + return frozen; +}; + +/** + * Get current context options or set a new context with defaults and + * fallback to an empty object. + * + * This should always return a valid object. In normal operations, + * the context should be set before any code runs, but we provide a + * fallback for safety. + * + * @returns {GlobalOptions} Current options from context or defaults + */ +const getOptions = (): GlobalOptions => { + const context = optionsContext.getStore(); + + if (context) { + return context; + } + + return setOptions({}); +}; + +/** + * Run a function with specific options context. Useful for testing or programmatic usage. + * + * @param options - Options to use in context + * @param callback - Function to apply options context against + * @returns {Promise} Result of function + */ +const runWithOptions = async ( + options: GlobalOptions, + callback: () => Promise +): Promise => { + const frozen = Object.freeze(structuredClone(options)); + + return optionsContext.run(frozen, callback); +}; + +export { getOptions, optionsContext, runWithOptions, setOptions }; + diff --git a/src/options.defaults.ts b/src/options.defaults.ts new file mode 100644 index 0000000..d731b56 --- /dev/null +++ b/src/options.defaults.ts @@ -0,0 +1,187 @@ +import { join } from 'node:path'; +import packageJson from '../package.json'; + +/** + * Application defaults (not user-configurable) + */ +interface DefaultOptions { + resourceMemoOptions: Partial; + toolMemoOptions: Partial; + pfExternal: string; + pfExternalCharts: string; + pfExternalChartsComponents: string; + pfExternalChartsDesign: string; + pfExternalDesign: string; + pfExternalDesignComponents: string; + pfExternalDesignLayouts: string; + pfExternalAccessibility: string; + separator: string; + urlRegex: RegExp; + name: string; + version: string; + repoName: string | undefined; + contextPath: string; + docsPath: string; + llmsFilesPath: string; +} + +/** + * Default separator for joining multiple document contents + */ +const DEFAULT_SEPARATOR = '\n\n---\n\n'; + +/** + * Resource-level memoization options + */ +const RESOURCE_MEMO_OPTIONS = { + fetchUrl: { + cacheLimit: 100, + expire: 3 * 60 * 1000, // 3 minute sliding cache + cacheErrors: false + }, + readFile: { + cacheLimit: 50, + expire: 2 * 60 * 1000, // 2 minute sliding cache + cacheErrors: false + } +}; + +/** + * Tool-specific memoization options + */ +const TOOL_MEMO_OPTIONS = { + usePatternFlyDocs: { + cacheLimit: 10, + expire: 1 * 60 * 1000, // 1 minute sliding cache + cacheErrors: false + }, + fetchDocs: { + cacheLimit: 15, + expire: 1 * 60 * 1000, // 1 minute sliding cache + cacheErrors: false + } +}; + +/** + * URL regex pattern for detecting external URLs + */ +const URL_REGEX = /^(https?:)\/\//i; + +/** + * PatternFly docs root URL + */ +const PF_EXTERNAL = 'https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content'; + +/** + * PatternFly design guidelines URL + */ +const PF_EXTERNAL_DESIGN = `${PF_EXTERNAL}/design-guidelines`; + +/** + * PatternFly design guidelines' components' URL + */ +const PF_EXTERNAL_DESIGN_COMPONENTS = `${PF_EXTERNAL_DESIGN}/components`; + +/** + * PatternFly design guidelines' layouts' URL + */ +const PF_EXTERNAL_DESIGN_LAYOUTS = `${PF_EXTERNAL_DESIGN}/layouts`; + +/** + * PatternFly accessibility URL + */ +const PF_EXTERNAL_ACCESSIBILITY = `${PF_EXTERNAL}/accessibility`; + +/** + * PatternFly charts root URL + */ +const PF_EXTERNAL_CHARTS = 'https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src'; + +/** + * PatternFly charts' components' URL + */ +const PF_EXTERNAL_CHARTS_COMPONENTS = `${PF_EXTERNAL_CHARTS}/victory/components`; + +/** + * PatternFly charts' design guidelines URL + */ +const PF_EXTERNAL_CHARTS_DESIGN = `${PF_EXTERNAL_CHARTS}/charts`; + +/** + * Global default options. Base defaults before CLI/programmatic overrides. + * + * @type {GlobalOptions} Default options object. + * @property {CliOptions.docsHost} [docsHost] - Flag indicating whether to use the docs-host. + * @property {string} pfExternal - PatternFly external docs URL. + * @property {string} pfExternalCharts - PatternFly external charts URL. + * @property {string} pfExternalChartsComponents - PatternFly external charts components URL. + * @property {string} pfExternalChartsDesign - PatternFly external charts design guidelines URL. + * @property {string} pfExternalDesign - PatternFly external design guidelines URL. + * @property {string} pfExternalDesignComponents - PatternFly external design guidelines components URL. + * @property {string} pfExternalDesignLayouts - PatternFly external design guidelines layouts URL. + * @property {string} pfExternalAccessibility - PatternFly external accessibility URL. + * @property {typeof RESOURCE_MEMO_OPTIONS} resourceMemoOptions - Resource-level memoization options. + * @property {typeof TOOL_MEMO_OPTIONS} toolMemoOptions - Tool-specific memoization options. + * @property {string} separator - Default string delimiter. + * @property {RegExp} urlRegex - Regular expression pattern for URL matching. + * @property {string} name - Name of the package. + * @property {string} version - Version of the package. + * @property {string} repoName - Name of the repository. + * @property {string} contextPath - Current working directory. + * @property {string} docsPath - Path to the documentation directory. + * @property {string} llmsFilesPath - Path to the LLMs files directory. + */ +const DEFAULT_OPTIONS: DefaultOptions = { + pfExternal: PF_EXTERNAL, + pfExternalCharts: PF_EXTERNAL_CHARTS, + pfExternalChartsComponents: PF_EXTERNAL_CHARTS_COMPONENTS, + pfExternalChartsDesign: PF_EXTERNAL_CHARTS_DESIGN, + pfExternalDesign: PF_EXTERNAL_DESIGN, + pfExternalDesignComponents: PF_EXTERNAL_DESIGN_COMPONENTS, + pfExternalDesignLayouts: PF_EXTERNAL_DESIGN_LAYOUTS, + pfExternalAccessibility: PF_EXTERNAL_ACCESSIBILITY, + resourceMemoOptions: RESOURCE_MEMO_OPTIONS, + toolMemoOptions: TOOL_MEMO_OPTIONS, + separator: DEFAULT_SEPARATOR, + urlRegex: URL_REGEX, + name: packageJson.name, + version: (process.env.NODE_ENV === 'local' && '0.0.0') || packageJson.version, + repoName: process.cwd()?.split?.('/')?.pop?.()?.trim?.(), + contextPath: (process.env.NODE_ENV === 'local' && '/') || process.cwd(), + docsPath: (process.env.NODE_ENV === 'local' && '/documentation') || join(process.cwd(), 'documentation'), + llmsFilesPath: (process.env.NODE_ENV === 'local' && '/llms-files') || join(process.cwd(), 'llms-files') +}; + +const DEFAULTS = { + PF_EXTERNAL, + PF_EXTERNAL_CHARTS, + PF_EXTERNAL_CHARTS_COMPONENTS, + PF_EXTERNAL_CHARTS_DESIGN, + PF_EXTERNAL_DESIGN, + PF_EXTERNAL_DESIGN_COMPONENTS, + PF_EXTERNAL_DESIGN_LAYOUTS, + PF_EXTERNAL_ACCESSIBILITY, + RESOURCE_MEMO_OPTIONS, + TOOL_MEMO_OPTIONS, + DEFAULT_OPTIONS, + DEFAULT_SEPARATOR, + URL_REGEX +}; + +export { + DEFAULTS, + PF_EXTERNAL, + PF_EXTERNAL_CHARTS, + PF_EXTERNAL_CHARTS_COMPONENTS, + PF_EXTERNAL_CHARTS_DESIGN, + PF_EXTERNAL_DESIGN, + PF_EXTERNAL_DESIGN_COMPONENTS, + PF_EXTERNAL_DESIGN_LAYOUTS, + PF_EXTERNAL_ACCESSIBILITY, + RESOURCE_MEMO_OPTIONS, + TOOL_MEMO_OPTIONS, + DEFAULT_OPTIONS, + DEFAULT_SEPARATOR, + URL_REGEX, + type DefaultOptions +}; diff --git a/src/options.ts b/src/options.ts index 3d718f4..c3ea32e 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,4 @@ -import { join } from 'node:path'; -import packageJson from '../package.json'; +import { type DefaultOptions } from './options.defaults'; /** * CLI options that users can set via command line arguments @@ -10,163 +9,12 @@ interface CliOptions { } /** - * Application defaults (not user-configurable) + * Combined options object */ -interface AppDefaults { - resourceMemoOptions: typeof RESOURCE_MEMO_OPTIONS; - toolMemoOptions: typeof TOOL_MEMO_OPTIONS; - pfExternal: string; - pfExternalCharts: string; - pfExternalChartsComponents: string; - pfExternalChartsDesign: string; - pfExternalDesign: string; - pfExternalDesignComponents: string; - pfExternalDesignLayouts: string; - pfExternalAccessibility: string; - separator: string; - urlRegex: RegExp; - name: string; - version: string; - repoName: string | undefined; - contextPath: string; - docsPath: string; - llmsFilesPath: string; +interface GlobalOptions extends CliOptions, DefaultOptions { + // Combined DefaultOptions and CliOptions } -/** - * Frozen options object (immutable configuration) - */ -interface GlobalOptions extends CliOptions, AppDefaults { - // This will be frozen and immutable -} - -/** - * Default separator for joining multiple document contents - */ -const DEFAULT_SEPARATOR = '\n\n---\n\n'; - -/** - * Resource-level memoization options - */ -const RESOURCE_MEMO_OPTIONS = { - fetchUrl: { - cacheLimit: 100, - expire: 3 * 60 * 1000, // 3 minute sliding cache - cacheErrors: false - }, - readFile: { - cacheLimit: 50, - expire: 2 * 60 * 1000, // 2 minute sliding cache - cacheErrors: false - } -}; - -/** - * Tool-specific memoization options - */ -const TOOL_MEMO_OPTIONS = { - usePatternFlyDocs: { - cacheLimit: 10, - expire: 1 * 60 * 1000, // 1 minute sliding cache - cacheErrors: false - }, - fetchDocs: { - cacheLimit: 15, - expire: 1 * 60 * 1000, // 1 minute sliding cache - cacheErrors: false - } -}; - -/** - * URL regex pattern for detecting external URLs - */ -const URL_REGEX = /^(https?:)\/\//i; - -/** - * PatternFly docs root URL - */ -const PF_EXTERNAL = 'https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content'; - -/** - * PatternFly design guidelines URL - */ -const PF_EXTERNAL_DESIGN = `${PF_EXTERNAL}/design-guidelines`; - -/** - * PatternFly design guidelines' components' URL - */ -const PF_EXTERNAL_DESIGN_COMPONENTS = `${PF_EXTERNAL_DESIGN}/components`; - -/** - * PatternFly design guidelines' layouts' URL - */ -const PF_EXTERNAL_DESIGN_LAYOUTS = `${PF_EXTERNAL_DESIGN}/layouts`; - -/** - * PatternFly accessibility URL - */ -const PF_EXTERNAL_ACCESSIBILITY = `${PF_EXTERNAL}/accessibility`; - -/** - * PatternFly charts root URL - */ -const PF_EXTERNAL_CHARTS = 'https://raw.githubusercontent.com/patternfly/patternfly-react/refs/heads/main/packages/react-charts/src'; - -/** - * PatternFly charts' components' URL - */ -const PF_EXTERNAL_CHARTS_COMPONENTS = `${PF_EXTERNAL_CHARTS}/victory/components`; - -/** - * PatternFly charts' design guidelines URL - */ -const PF_EXTERNAL_CHARTS_DESIGN = `${PF_EXTERNAL_CHARTS}/charts`; - -/** - * Global configuration options object. - * - * @type {GlobalOptions} - * @property {CliOptions.docsHost} [docsHost] - Flag indicating whether to use the docs-host. - * @property {string} pfExternal - PatternFly external docs URL. - * @property {string} pfExternalCharts - PatternFly external charts URL. - * @property {string} pfExternalChartsComponents - PatternFly external charts components URL. - * @property {string} pfExternalChartsDesign - PatternFly external charts design guidelines URL. - * @property {string} pfExternalDesign - PatternFly external design guidelines URL. - * @property {string} pfExternalDesignComponents - PatternFly external design guidelines components URL. - * @property {string} pfExternalDesignLayouts - PatternFly external design guidelines layouts URL. - * @property {string} pfExternalAccessibility - PatternFly external accessibility URL. - * @property {typeof RESOURCE_MEMO_OPTIONS} resourceMemoOptions - Resource-level memoization options. - * @property {typeof TOOL_MEMO_OPTIONS} toolMemoOptions - Tool-specific memoization options. - * @property {string} separator - Default string delimiter. - * @property {RegExp} urlRegex - Regular expression pattern for URL matching. - * @property {string} name - Name of the package. - * @property {string} version - Version of the package. - * @property {string} repoName - Name of the repository. - * @property {string} contextPath - Current working directory. - * @property {string} docsPath - Path to the documentation directory. - * @property {string} llmsFilesPath - Path to the LLMs files directory. - */ -const OPTIONS: GlobalOptions = { - pfExternal: PF_EXTERNAL, - pfExternalCharts: PF_EXTERNAL_CHARTS, - pfExternalChartsComponents: PF_EXTERNAL_CHARTS_COMPONENTS, - pfExternalChartsDesign: PF_EXTERNAL_CHARTS_DESIGN, - pfExternalDesign: PF_EXTERNAL_DESIGN, - pfExternalDesignComponents: PF_EXTERNAL_DESIGN_COMPONENTS, - pfExternalDesignLayouts: PF_EXTERNAL_DESIGN_LAYOUTS, - pfExternalAccessibility: PF_EXTERNAL_ACCESSIBILITY, - resourceMemoOptions: RESOURCE_MEMO_OPTIONS, - toolMemoOptions: TOOL_MEMO_OPTIONS, - separator: DEFAULT_SEPARATOR, - urlRegex: URL_REGEX, - name: packageJson.name, - version: (process.env.NODE_ENV === 'local' && '0.0.0') || packageJson.version, - repoName: process.cwd()?.split?.('/')?.pop?.()?.trim?.(), - contextPath: (process.env.NODE_ENV === 'local' && '/') || process.cwd(), - docsPath: (process.env.NODE_ENV === 'local' && '/documentation') || join(process.cwd(), 'documentation'), - llmsFilesPath: (process.env.NODE_ENV === 'local' && '/llms-files') || join(process.cwd(), 'llms-files') -}; - /** * Parse CLI arguments and return CLI options */ @@ -175,36 +23,9 @@ const parseCliOptions = (): CliOptions => ({ // Future CLI options can be added here }); -/** - * Make global options immutable after combining CLI options with app defaults. - * - * @param cliOptions - */ -const freezeOptions = (cliOptions: CliOptions) => { - Object.assign(OPTIONS, { - ...cliOptions - }); - - return Object.freeze(OPTIONS); -}; - export { parseCliOptions, - freezeOptions, - OPTIONS, - PF_EXTERNAL, - PF_EXTERNAL_CHARTS, - PF_EXTERNAL_CHARTS_COMPONENTS, - PF_EXTERNAL_CHARTS_DESIGN, - PF_EXTERNAL_DESIGN, - PF_EXTERNAL_DESIGN_COMPONENTS, - PF_EXTERNAL_DESIGN_LAYOUTS, - PF_EXTERNAL_ACCESSIBILITY, - RESOURCE_MEMO_OPTIONS, - TOOL_MEMO_OPTIONS, - DEFAULT_SEPARATOR, - URL_REGEX, type CliOptions, - type AppDefaults, + type DefaultOptions, type GlobalOptions }; diff --git a/src/server.getResources.ts b/src/server.getResources.ts index c25ae81..14f291a 100644 --- a/src/server.getResources.ts +++ b/src/server.getResources.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { OPTIONS } from './options'; +import { getOptions } from './options.context'; +import { RESOURCE_MEMO_OPTIONS } from './options.defaults'; import { memo } from './server.caching'; /** @@ -11,9 +12,9 @@ import { memo } from './server.caching'; const readLocalFileFunction = async (filePath: string) => await readFile(filePath, 'utf-8'); /** - * Memoized version of readLocalFileFunction + * Memoized version of readLocalFileFunction. Use default memo options. */ -readLocalFileFunction.memo = memo(readLocalFileFunction, OPTIONS.resourceMemoOptions.readFile); +readLocalFileFunction.memo = memo(readLocalFileFunction, RESOURCE_MEMO_OPTIONS.readFile); /** * Fetch content from a URL with timeout and error handling @@ -42,9 +43,9 @@ const fetchUrlFunction = async (url: string) => { }; /** - * Memoized version of fetchUrlFunction + * Memoized version of fetchUrlFunction. Use default memo options. */ -fetchUrlFunction.memo = memo(fetchUrlFunction, OPTIONS.resourceMemoOptions.fetchUrl); +fetchUrlFunction.memo = memo(fetchUrlFunction, RESOURCE_MEMO_OPTIONS.fetchUrl); /** * Resolve a local path depending on docs host flag @@ -52,8 +53,12 @@ fetchUrlFunction.memo = memo(fetchUrlFunction, OPTIONS.resourceMemoOptions.fetch * @param relativeOrAbsolute * @param options */ -const resolveLocalPathFunction = (relativeOrAbsolute: string, options = OPTIONS) => - (options.docsHost && join(options.llmsFilesPath, relativeOrAbsolute)) || relativeOrAbsolute; +const resolveLocalPathFunction = (relativeOrAbsolute: string, options = getOptions()) => { + const useHost = Boolean(options?.docsHost); + const base = options?.llmsFilesPath; + + return (useHost && join(base, relativeOrAbsolute)) || relativeOrAbsolute; +}; /** * Normalize inputs, load all in parallel, and return a joined string. @@ -63,7 +68,7 @@ const resolveLocalPathFunction = (relativeOrAbsolute: string, options = OPTIONS) */ const processDocsFunction = async ( inputs: string[], - options = OPTIONS + options = getOptions() ) => { const seen = new Set(); const list = inputs diff --git a/src/server.ts b/src/server.ts index 08b5cfd..a8b5493 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,11 +3,12 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; import { fetchDocsTool } from './tool.fetchDocs'; import { componentSchemasTool } from './tool.componentSchemas'; -import { OPTIONS } from './options'; +import { getOptions, runWithOptions } from './options.context'; +import { type GlobalOptions } from './options'; type McpTool = [string, { description: string; inputSchema: any }, (args: any) => Promise]; -type McpToolCreator = (options?: any) => McpTool; +type McpToolCreator = (options?: GlobalOptions) => McpTool; /** * Server instance with shutdown capability @@ -33,7 +34,7 @@ interface ServerInstance { * @param settings.tools * @param settings.enableSigint */ -const runServer = async (options = OPTIONS, { +const runServer = async (options = getOptions(), { tools = [ usePatternFlyDocsTool, fetchDocsTool, @@ -68,10 +69,10 @@ const runServer = async (options = OPTIONS, { ); tools.forEach(toolCreator => { - const [name, schema, callback] = toolCreator(); + const [name, schema, callback] = toolCreator(options); console.info(`Registered tool: ${name}`); - server?.registerTool(name, schema, callback); + server?.registerTool(name, schema, (args = {}) => runWithOptions(options, async () => await callback(args))); }); if (enableSigint) { diff --git a/src/tool.componentSchemas.ts b/src/tool.componentSchemas.ts index 52561e4..0fb2b2b 100644 --- a/src/tool.componentSchemas.ts +++ b/src/tool.componentSchemas.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { componentNames, getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { type McpTool } from './server'; -import { OPTIONS } from './options'; +import { getOptions } from './options.context'; import { memo } from './server.caching'; import { fuzzySearch } from './server.search'; @@ -20,10 +20,10 @@ type ComponentSchema = Awaited>; * @param options - Optional configuration options (defaults to OPTIONS) * @returns {McpTool} MCP tool tuple [name, schema, callback] */ -const componentSchemasTool = (options = OPTIONS): McpTool => { +const componentSchemasTool = (options = getOptions()): McpTool => { const memoGetComponentSchema = memo( async (componentName: string): Promise => getComponentSchema(componentName), - options.toolMemoOptions.fetchDocs // Use the same memo options as fetchDocs + options?.toolMemoOptions?.fetchDocs // Use the same memo options as fetchDocs ); const callback = async (args: any = {}) => { diff --git a/src/tool.fetchDocs.ts b/src/tool.fetchDocs.ts index 63cc5f6..7d5f450 100644 --- a/src/tool.fetchDocs.ts +++ b/src/tool.fetchDocs.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { type McpTool } from './server'; import { processDocsFunction } from './server.getResources'; -import { OPTIONS } from './options'; +import { getOptions } from './options.context'; import { memo } from './server.caching'; /** @@ -10,8 +10,8 @@ import { memo } from './server.caching'; * * @param options */ -const fetchDocsTool = (options = OPTIONS): McpTool => { - const memoProcess = memo(processDocsFunction, options.toolMemoOptions.fetchDocs); +const fetchDocsTool = (options = getOptions()): McpTool => { + const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.fetchDocs); const callback = async (args: any = {}) => { const { urlList } = args; diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index 73624eb..bec3614 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -5,8 +5,8 @@ import { type McpTool } from './server'; import { COMPONENT_DOCS } from './docs.component'; import { LAYOUT_DOCS } from './docs.layout'; import { CHART_DOCS } from './docs.chart'; -import { LOCAL_DOCS } from './docs.local'; -import { OPTIONS } from './options'; +import { getLocalDocs } from './docs.local'; +import { getOptions } from './options.context'; import { processDocsFunction } from './server.getResources'; import { memo } from './server.caching'; @@ -15,8 +15,8 @@ import { memo } from './server.caching'; * * @param options */ -const usePatternFlyDocsTool = (options = OPTIONS): McpTool => { - const memoProcess = memo(processDocsFunction, options.toolMemoOptions.usePatternFlyDocs); +const usePatternFlyDocsTool = (options = getOptions()): McpTool => { + const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.usePatternFlyDocs); const callback = async (args: any = {}) => { const { urlList } = args; @@ -62,7 +62,7 @@ const usePatternFlyDocsTool = (options = OPTIONS): McpTool => { ${COMPONENT_DOCS.join('\n')} ${LAYOUT_DOCS.join('\n')} ${CHART_DOCS.join('\n')} - ${LOCAL_DOCS.join('\n')} + ${getLocalDocs().join('\n')} ` }