diff --git a/src/__mocks__/utils.moduleResolver.ts b/src/__mocks__/utils.moduleResolver.ts new file mode 100644 index 0000000..f7c8d90 --- /dev/null +++ b/src/__mocks__/utils.moduleResolver.ts @@ -0,0 +1,8 @@ +/** + * Mock implementation of utils.moduleResolver for Jest tests + * This avoids import.meta.resolve compatibility issues in test environment + */ + +export const resolveModule = jest.fn((modulePath: string): string => + // Default mock behavior - can be overridden in individual tests + `file://${modulePath}`); diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index 45ab916..af1157c 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -230,6 +230,15 @@ exports[`runServer should attempt to run server, use default tools: console 1`] [ "Registered tool: componentSchemas", ], + [ + "Registered tool: getAvailableModules", + ], + [ + "Registered tool: getComponentSourceCode", + ], + [ + "Registered tool: getReactUtilityClasses", + ], ], "log": [ [ @@ -678,6 +687,309 @@ exports[`runServer should attempt to run server, use default tools: console 1`] }, [Function], ], + [ + "getAvailableModules", + { + "description": "Retrieves a list of available Patternfly react-core modules in the current environment.", + "inputSchema": { + "packageName": ZodDefault { + "_def": { + "defaultValue": [Function], + "description": "Name of the patternfly package to get modules for. For tables its always better to use the @patternfly/react-data-view package.", + "errorMap": [Function], + "innerType": ZodEnum { + "_def": { + "description": "Name of the patternfly package to get modules for. For tables its always better to use the @patternfly/react-data-view package.", + "typeName": "ZodEnum", + "values": [ + "@patternfly/react-core", + "@patternfly/react-icons", + "@patternfly/react-table", + "@patternfly/react-data-view", + "@patternfly/react-component-groups", + ], + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodDefault", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], + ], + [ + "getComponentSourceCode", + { + "description": "Retrieve a source code of a specified Patternfly react-core module in the current environment.", + "inputSchema": { + "componentName": ZodString { + "_def": { + "checks": [], + "coerce": false, + "description": "Name of the PatternFly component (e.g., "Button", "Table")", + "typeName": "ZodString", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "packageName": ZodDefault { + "_def": { + "defaultValue": [Function], + "description": "Name of the patternfly package to get component from. For tables its always better to use the @patternfly/react-data-view package.", + "errorMap": [Function], + "innerType": ZodOptional { + "_def": { + "description": "Name of the patternfly package to get component from. For tables its always better to use the @patternfly/react-data-view package.", + "errorMap": [Function], + "innerType": ZodEnum { + "_def": { + "typeName": "ZodEnum", + "values": [ + "@patternfly/react-core", + "@patternfly/react-table", + "@patternfly/react-data-view", + "@patternfly/react-component-groups", + ], + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodOptional", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodDefault", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], + ], + [ + "getReactUtilityClasses", + { + "description": "Retrieves a list of available Patternfly react-styles utility classes in the current environment.", + "inputSchema": { + "utilityName": ZodEnum { + "_def": { + "description": "Name of a set of utility classes to retrieve from @patternfly/react-styles.", + "typeName": "ZodEnum", + "values": [ + "Accessibility", + "Alignment", + "BackgroundColor", + "BoxShadow", + "Display", + "Flex", + "Float", + "Sizing", + "Spacing", + "Text", + ], + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], + ], ], } `; diff --git a/src/__tests__/__snapshots__/tool.getAvailableModules.test.ts.snap b/src/__tests__/__snapshots__/tool.getAvailableModules.test.ts.snap new file mode 100644 index 0000000..295ebd0 --- /dev/null +++ b/src/__tests__/__snapshots__/tool.getAvailableModules.test.ts.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`getAvailableModulesTool should have a consistent return structure: structure 1`] = ` +[ + "getAvailableModules", + { + "description": "Retrieves a list of available Patternfly react-core modules in the current environment.", + "inputSchema": { + "packageName": ZodDefault { + "_def": { + "defaultValue": [Function], + "description": "Name of the patternfly package to get modules for. For tables its always better to use the @patternfly/react-data-view package.", + "errorMap": [Function], + "innerType": ZodEnum { + "_def": { + "description": "Name of the patternfly package to get modules for. For tables its always better to use the @patternfly/react-data-view package.", + "typeName": "ZodEnum", + "values": [ + "@patternfly/react-core", + "@patternfly/react-icons", + "@patternfly/react-table", + "@patternfly/react-data-view", + "@patternfly/react-component-groups", + ], + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodDefault", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], +] +`; + +exports[`getAvailableModulesTool, callback should handle modules with special characters in names 1`] = ` +{ + "content": [ + { + "text": "Button-Group;Data.Table;Nav_Item", + "type": "text", + }, + ], +} +`; + +exports[`getAvailableModulesTool, callback should return empty string when modules map is empty 1`] = ` +{ + "content": [ + { + "text": "", + "type": "text", + }, + ], +} +`; + +exports[`getAvailableModulesTool, callback should return modules list separated by semicolons 1`] = ` +{ + "content": [ + { + "text": "Button;Card;Table", + "type": "text", + }, + ], +} +`; diff --git a/src/__tests__/__snapshots__/tool.getComponentSourceCode.test.ts.snap b/src/__tests__/__snapshots__/tool.getComponentSourceCode.test.ts.snap new file mode 100644 index 0000000..4324049 --- /dev/null +++ b/src/__tests__/__snapshots__/tool.getComponentSourceCode.test.ts.snap @@ -0,0 +1,185 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`getComponentSourceCode should have a consistent return structure: structure 1`] = ` +[ + "getComponentSourceCode", + { + "description": "Retrieve a source code of a specified Patternfly react-core module in the current environment.", + "inputSchema": { + "componentName": ZodString { + "_def": { + "checks": [], + "coerce": false, + "description": "Name of the PatternFly component (e.g., "Button", "Table")", + "typeName": "ZodString", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "packageName": ZodDefault { + "_def": { + "defaultValue": [Function], + "description": "Name of the patternfly package to get component from. For tables its always better to use the @patternfly/react-data-view package.", + "errorMap": [Function], + "innerType": ZodOptional { + "_def": { + "description": "Name of the patternfly package to get component from. For tables its always better to use the @patternfly/react-data-view package.", + "errorMap": [Function], + "innerType": ZodEnum { + "_def": { + "typeName": "ZodEnum", + "values": [ + "@patternfly/react-core", + "@patternfly/react-table", + "@patternfly/react-data-view", + "@patternfly/react-component-groups", + ], + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodOptional", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + "typeName": "ZodDefault", + }, + "and": [Function], + "array": [Function], + "brand": [Function], + "catch": [Function], + "default": [Function], + "describe": [Function], + "isNullable": [Function], + "isOptional": [Function], + "nullable": [Function], + "nullish": [Function], + "optional": [Function], + "or": [Function], + "parse": [Function], + "parseAsync": [Function], + "pipe": [Function], + "promise": [Function], + "readonly": [Function], + "refine": [Function], + "refinement": [Function], + "safeParse": [Function], + "safeParseAsync": [Function], + "spa": [Function], + "superRefine": [Function], + "transform": [Function], + "~standard": { + "validate": [Function], + "vendor": "zod", + "version": 1, + }, + }, + }, + }, + [Function], +] +`; + +exports[`getComponentSourceCode, callback successful component source retrieval should handle .tsx file when .ts file not found 1`] = ` +{ + "content": [ + { + "text": "export const Button = () => { return ; };", + "type": "text", + }, + ], +} +`; + +exports[`getComponentSourceCode, callback successful component source retrieval should retrieve component source code successfully 1`] = ` +{ + "content": [ + { + "text": "export const Button = () => { return ; };", + "type": "text", + }, + ], +} +`; diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 39ad80f..52a49d0 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -8,6 +8,7 @@ import { runServer } from '../server'; jest.mock('../options'); jest.mock('../options.context'); jest.mock('../server'); +jest.mock('../utils.moduleResolver'); // Mock the module resolver to avoid import.meta issues const mockParseCliOptions = parseCliOptions as jest.MockedFunction; const mockSetOptions = setOptions as jest.MockedFunction; diff --git a/src/__tests__/options.context.test.ts b/src/__tests__/options.context.test.ts index 38f6401..86ff4bb 100644 --- a/src/__tests__/options.context.test.ts +++ b/src/__tests__/options.context.test.ts @@ -6,6 +6,7 @@ import { getOptions, setOptions } from '../options.context'; // Mock dependencies jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); jest.mock('@modelcontextprotocol/sdk/server/stdio.js'); +jest.mock('../utils.moduleResolver'); // Mock the module resolver to avoid import.meta issues const MockMcpServer = McpServer as jest.MockedClass; const MockStdioServerTransport = StdioServerTransport as jest.MockedClass; diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 11c0196..25e9543 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -6,6 +6,7 @@ import { type GlobalOptions } from '../options'; // Mock dependencies jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); jest.mock('@modelcontextprotocol/sdk/server/stdio.js'); +jest.mock('../utils.moduleResolver'); // Mock the module resolver to avoid import.meta issues const MockMcpServer = McpServer as jest.MockedClass; const MockStdioServerTransport = StdioServerTransport as jest.MockedClass; diff --git a/src/__tests__/tool.getAvailableModules.test.ts b/src/__tests__/tool.getAvailableModules.test.ts new file mode 100644 index 0000000..d0762b5 --- /dev/null +++ b/src/__tests__/tool.getAvailableModules.test.ts @@ -0,0 +1,103 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { getAvailableModulesTool } from '../tool.getAvailableModules'; +import { getLocalModulesMap } from '../utils.getLocalModulesMap'; + +// Mock dependencies +jest.mock('../utils.getLocalModulesMap'); +jest.mock('../utils.moduleResolver'); // Mock the module resolver to avoid import.meta issues +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); + +const mockGetLocalModulesMap = getLocalModulesMap as jest.MockedFunction; + +describe('getAvailableModulesTool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const tool = getAvailableModulesTool(); + + expect(tool).toMatchSnapshot('structure'); + }); +}); + +describe('getAvailableModulesTool, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return modules list separated by semicolons', async () => { + const mockModulesMap = { + Button: '/path/to/button', + Card: '/path/to/card', + Table: '/path/to/table' + }; + + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + + const [, , callback] = getAvailableModulesTool(); + const result = await callback({ packageName: '@patternfly/react-core' }); + + expect(mockGetLocalModulesMap).toHaveBeenCalledWith('@patternfly/react-core'); + expect(result).toMatchSnapshot(); + }); + + it('should return empty string when modules map is empty', async () => { + mockGetLocalModulesMap.mockResolvedValue({}); + + const [, , callback] = getAvailableModulesTool(); + const result = await callback({ packageName: '@patternfly/react-core' }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle modules with special characters in names', async () => { + const mockModulesMap = { + 'Button-Group': '/path/to/button-group', + 'Data.Table': '/path/to/data-table', + Nav_Item: '/path/to/nav-item' + }; + + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + + const [, , callback] = getAvailableModulesTool(); + const result = await callback({ packageName: '@patternfly/react-core' }); + + expect(result).toMatchSnapshot(); + }); + + it.each([ + { + description: 'with Error object', + error: new Error('Package not found') + }, + { + description: 'with string error', + error: 'String error message' + }, + { + description: 'with null error', + error: null + } + ])('should handle getLocalModulesMap errors, $description', async ({ error }) => { + mockGetLocalModulesMap.mockRejectedValue(error); + + const [, , callback] = getAvailableModulesTool(); + + await expect(callback({ packageName: '@patternfly/react-core' })).rejects.toThrow(McpError); + await expect(callback({ packageName: '@patternfly/react-core' })).rejects.toThrow('Failed to retrieve available modules'); + }); + + it('should always call with @patternfly/react-core package', async () => { + mockGetLocalModulesMap.mockResolvedValue({ Component: '/path' }); + + const [, , callback] = getAvailableModulesTool(); + + await callback({ packageName: '@patternfly/react-core' }); + + expect(mockGetLocalModulesMap).toHaveBeenCalledWith('@patternfly/react-core'); + expect(mockGetLocalModulesMap).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/tool.getComponentSourceCode.test.ts b/src/__tests__/tool.getComponentSourceCode.test.ts new file mode 100644 index 0000000..6c0802a --- /dev/null +++ b/src/__tests__/tool.getComponentSourceCode.test.ts @@ -0,0 +1,265 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { getComponentSourceCode } from '../tool.getComponentSourceCode'; +import { getLocalModulesMap } from '../utils.getLocalModulesMap'; +import { verifyLocalPackage } from '../utils.verifyLocalPackage'; +import { readFileAsync } from '../utils.readFile'; + +// Mock dependencies +jest.mock('../utils.getLocalModulesMap'); +jest.mock('../utils.verifyLocalPackage'); +jest.mock('../utils.readFile'); +jest.mock('../utils.moduleResolver'); // Mock the module resolver to avoid import.meta issues +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); + +const mockGetLocalModulesMap = getLocalModulesMap as jest.MockedFunction; +const mockVerifyLocalPackage = verifyLocalPackage as jest.MockedFunction; +const mockReadFileAsync = readFileAsync as jest.MockedFunction; + +describe('getComponentSourceCode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const tool = getComponentSourceCode(); + + expect(tool).toMatchSnapshot('structure'); + }); +}); + +describe('getComponentSourceCode, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('successful component source retrieval', () => { + it('should retrieve component source code successfully', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/node_modules/@patternfly/react-core' + }; + const mockModulesMap = { + Button: 'dist/dynamic/components/Button' + }; + const mockIndexSource = `export * from './Button';`; + const mockComponentSource = `export const Button = () => { return ; };`; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + mockReadFileAsync + .mockResolvedValueOnce(mockIndexSource) // index.ts read + .mockResolvedValueOnce(mockComponentSource); // component file read + + const [, , callback] = getComponentSourceCode(); + const result = await callback({ componentName: 'Button' }); + + expect(mockVerifyLocalPackage).toHaveBeenCalledWith('@patternfly/react-core'); + expect(mockGetLocalModulesMap).toHaveBeenCalledWith('@patternfly/react-core'); + expect(result).toMatchSnapshot(); + }); + + it('should handle .tsx file when .ts file not found', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/node_modules/@patternfly/react-core' + }; + const mockModulesMap = { + Button: 'dist/dynamic/components/Button' + }; + const mockIndexSource = `export * from './Button';`; + const mockComponentSource = `export const Button = () => { return ; };`; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + mockReadFileAsync + .mockResolvedValueOnce(mockIndexSource) // index.ts read + .mockRejectedValueOnce(new Error('File not found')) // .ts file fails + .mockResolvedValueOnce(mockComponentSource); // .tsx file succeeds + + const [, , callback] = getComponentSourceCode(); + const result = await callback({ componentName: 'Button' }); + + expect(result).toMatchSnapshot(); + }); + }); + + describe('parameter validation', () => { + it.each([ + { + description: 'with missing componentName', + componentName: undefined, + error: 'Missing required parameter: componentName' + }, + { + description: 'with null componentName', + componentName: null, + error: 'Missing required parameter: componentName' + }, + { + description: 'with non-string componentName', + componentName: 123, + error: 'Missing required parameter: componentName' + }, + { + description: 'with array componentName', + componentName: ['Button'], + error: 'Missing required parameter: componentName' + } + ])('should handle invalid parameters, $description', async ({ componentName, error }) => { + const [, , callback] = getComponentSourceCode(); + + await expect(callback({ componentName })).rejects.toThrow(McpError); + await expect(callback({ componentName })).rejects.toThrow(error); + }); + }); + + describe('package verification errors', () => { + it('should handle package not found', async () => { + const mockPackageStatus = { + exists: false, + version: '', + packageRoot: '', + error: new Error('Package not found') + }; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + + const [, , callback] = getComponentSourceCode(); + + await expect(callback({ componentName: 'Button' })).rejects.toThrow(McpError); + await expect(callback({ componentName: 'Button' })).rejects.toThrow('Package "@patternfly/react-core" not found locally'); + }); + }); + + describe('component resolution errors', () => { + it('should handle component not found in modules map', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/node_modules/@patternfly/react-core' + }; + const mockModulesMap = { + Card: 'dist/dynamic/components/Card' + }; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + + const [, , callback] = getComponentSourceCode(); + + await expect(callback({ componentName: 'Button' })).rejects.toThrow(McpError); + await expect(callback({ componentName: 'Button' })).rejects.toThrow('No valid path to "Button" found'); + }); + + it('should handle import line not found in index file', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/node_modules/@patternfly/react-core' + }; + const mockModulesMap = { + Button: 'dist/dynamic/components/Button' + }; + const mockIndexSource = `export * from './Card';`; // Different component - no Button export + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + mockReadFileAsync.mockResolvedValue(mockIndexSource); + + const [, , callback] = getComponentSourceCode(); + + await expect(callback({ componentName: 'Button' })).rejects.toThrow(McpError); + await expect(callback({ componentName: 'Button' })).rejects.toThrow('Failed to find source code for component "Button"'); + }); + + it('should handle both .ts and .tsx files not found', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/node_modules/@patternfly/react-core' + }; + const mockModulesMap = { + Button: 'dist/dynamic/components/Button' + }; + const mockIndexSource = `export * from './Button';`; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + mockReadFileAsync + .mockImplementation(filePath => { + // @ts-ignore + if (filePath.includes('index.ts')) { + return Promise.resolve(mockIndexSource); + } + + return Promise.reject(new Error('File not found')); + }); + + const [, , callback] = getComponentSourceCode(); + + await expect(callback({ componentName: 'Button' })).rejects.toThrow(McpError); + await expect(callback({ componentName: 'Button' })).rejects.toThrow('Failed to read source code file for component "Button"'); + }); + }); + + describe('path handling', () => { + it('should correctly transform dist/dynamic path to src path', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/node_modules/@patternfly/react-core' + }; + const mockModulesMap = { + Button: 'dist/dynamic/components/Button' + }; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue(mockModulesMap); + + const [, , callback] = getComponentSourceCode(); + + // This will fail at readFileAsync but we can verify the path transformation logic + try { + await callback({ componentName: 'Button' }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Expected to fail, but we can verify the calls + } + + // Should read from src path, not dist path + expect(mockReadFileAsync).toHaveBeenCalledWith( + '/test/node_modules/@patternfly/react-core/src/components/Button/index.ts', + 'utf-8' + ); + }); + }); + + describe('always targets @patternfly/react-core', () => { + it('should always use @patternfly/react-core package', async () => { + const mockPackageStatus = { + exists: true, + version: '1.0.0', + packageRoot: '/test/path' + }; + + mockVerifyLocalPackage.mockResolvedValue(mockPackageStatus); + mockGetLocalModulesMap.mockResolvedValue({}); + + const [, , callback] = getComponentSourceCode(); + + try { + await callback({ componentName: 'Button' }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Expected to fail due to empty modules map + } + + expect(mockVerifyLocalPackage).toHaveBeenCalledWith('@patternfly/react-core'); + expect(mockGetLocalModulesMap).toHaveBeenCalledWith('@patternfly/react-core'); + }); + }); +}); diff --git a/src/__tests__/utils.verifyLocalPackage.test.ts b/src/__tests__/utils.verifyLocalPackage.test.ts new file mode 100644 index 0000000..b9935c4 --- /dev/null +++ b/src/__tests__/utils.verifyLocalPackage.test.ts @@ -0,0 +1,243 @@ +import { verifyLocalPackage } from '../utils.verifyLocalPackage'; +import { readJsonFile } from '../utils.readFile'; +import { resolveModule } from '../utils.moduleResolver'; + +// Mock dependencies +jest.mock('../utils.readFile'); +jest.mock('../utils.moduleResolver'); // This will use the __mocks__ version + +const mockReadJsonFile = readJsonFile as jest.MockedFunction; +const mockResolveModule = resolveModule as jest.MockedFunction; + +describe('verifyLocalPackage', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock process.cwd to return a consistent value for testing + jest.spyOn(process, 'cwd').mockReturnValue('/test/project'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('input validation', () => { + it('should return error for undefined package name', async () => { + const result = await verifyLocalPackage(undefined as any); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Invalid package name: undefined') + }); + }); + + it('should return error for null package name', async () => { + const result = await verifyLocalPackage(null as any); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Invalid package name: null') + }); + }); + + it('should return error for empty string package name', async () => { + const result = await verifyLocalPackage(''); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Invalid package name: ') + }); + }); + + it('should return error for non-string package name', async () => { + const result = await verifyLocalPackage(123 as any); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Invalid package name: 123') + }); + }); + }); + + describe('successful package resolution', () => { + it('should resolve package successfully with version', async () => { + const packageData = { version: '1.2.3' }; + const packagePath = 'file:///test/project/node_modules/@patternfly/react-core/package.json'; + + mockResolveModule.mockReturnValue(packagePath); + mockReadJsonFile.mockResolvedValue(packageData); + + const result = await verifyLocalPackage('@patternfly/react-core'); + + expect(mockResolveModule).toHaveBeenCalledWith('/test/project/node_modules/@patternfly/react-core/package.json'); + expect(mockReadJsonFile).toHaveBeenCalledWith('/test/project/node_modules/@patternfly/react-core/package.json'); + expect(result).toEqual({ + exists: true, + version: '1.2.3', + packageRoot: '/test/project/node_modules/@patternfly/react-core' + }); + }); + + it('should handle package without version field', async () => { + const packageData = {}; + const packagePath = 'file:///test/project/node_modules/some-package/package.json'; + + mockResolveModule.mockReturnValue(packagePath); + mockReadJsonFile.mockResolvedValue(packageData); + + const result = await verifyLocalPackage('some-package'); + + expect(result).toEqual({ + exists: true, + version: '', + packageRoot: '/test/project/node_modules/some-package' + }); + }); + + it('should handle scoped packages correctly', async () => { + const packageData = { version: '2.0.0' }; + const packagePath = 'file:///test/project/node_modules/@scope/package-name/package.json'; + + mockResolveModule.mockReturnValue(packagePath); + mockReadJsonFile.mockResolvedValue(packageData); + + const result = await verifyLocalPackage('@scope/package-name'); + + expect(mockResolveModule).toHaveBeenCalledWith('/test/project/node_modules/@scope/package-name/package.json'); + expect(result).toEqual({ + exists: true, + version: '2.0.0', + packageRoot: '/test/project/node_modules/@scope/package-name' + }); + }); + + it('should strip file:// protocol from paths', async () => { + const packageData = { version: '1.0.0' }; + const packagePath = 'file:///some/path/node_modules/package/package.json'; + + mockResolveModule.mockReturnValue(packagePath); + mockReadJsonFile.mockResolvedValue(packageData); + + const result = await verifyLocalPackage('package'); + + expect(mockReadJsonFile).toHaveBeenCalledWith('/some/path/node_modules/package/package.json'); + expect(result.packageRoot).toBe('/some/path/node_modules/package'); + }); + }); + + describe('error handling', () => { + it('should handle module resolution throwing error', async () => { + const resolveError = new Error('Module not found'); + + mockResolveModule.mockImplementation(() => { + throw resolveError; + }); + + const result = await verifyLocalPackage('nonexistent-package'); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Error resolving package "nonexistent-package": Module not found') + }); + }); + + it('should handle readJsonFile throwing error', async () => { + const packagePath = 'file:///test/project/node_modules/package/package.json'; + const readError = new Error('Permission denied'); + + mockResolveModule.mockReturnValue(packagePath); + mockReadJsonFile.mockRejectedValue(readError); + + const result = await verifyLocalPackage('package'); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Error resolving package "package": Permission denied') + }); + }); + + it('should handle non-Error objects being thrown', async () => { + mockResolveModule.mockImplementation(() => { + throw 'String error'; + }); + + const result = await verifyLocalPackage('package'); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Error resolving package "package": String error') + }); + }); + + it('should handle null/undefined errors', async () => { + mockResolveModule.mockImplementation(() => { + throw null; + }); + + const result = await verifyLocalPackage('package'); + + expect(result).toEqual({ + exists: false, + version: '', + packageRoot: '', + error: new Error('Error resolving package "package": null') + }); + }); + }); + + describe('path resolution', () => { + it('should use current working directory as project root', async () => { + const packageData = { version: '1.0.0' }; + + mockResolveModule.mockReturnValue('file:///test/project/node_modules/test/package.json'); + mockReadJsonFile.mockResolvedValue(packageData); + + await verifyLocalPackage('test'); + + expect(mockResolveModule).toHaveBeenCalledWith('/test/project/node_modules/test/package.json'); + }); + + it('should handle different working directories', async () => { + jest.spyOn(process, 'cwd').mockReturnValue('/different/path'); + + const packageData = { version: '1.0.0' }; + + mockResolveModule.mockReturnValue('file:///different/path/node_modules/test/package.json'); + mockReadJsonFile.mockResolvedValue(packageData); + + await verifyLocalPackage('test'); + + expect(mockResolveModule).toHaveBeenCalledWith('/different/path/node_modules/test/package.json'); + }); + }); + + describe('return type structure', () => { + it('should always return object with required properties', async () => { + mockResolveModule.mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await verifyLocalPackage('test'); + + expect(result).toHaveProperty('exists'); + expect(result).toHaveProperty('version'); + expect(result).toHaveProperty('packageRoot'); + expect(typeof result.exists).toBe('boolean'); + expect(typeof result.version).toBe('string'); + expect(typeof result.packageRoot).toBe('string'); + }); + }); +}); diff --git a/src/server.ts b/src/server.ts index a8b5493..06d1982 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,9 @@ import { fetchDocsTool } from './tool.fetchDocs'; import { componentSchemasTool } from './tool.componentSchemas'; import { getOptions, runWithOptions } from './options.context'; import { type GlobalOptions } from './options'; +import { getAvailableModulesTool } from './tool.getAvailableModules'; +import { getComponentSourceCode } from './tool.getComponentSourceCode'; +import { getReactUtilityClasses } from './tool.getReactUtilityClasses'; type McpTool = [string, { description: string; inputSchema: any }, (args: any) => Promise]; @@ -38,7 +41,10 @@ const runServer = async (options = getOptions(), { tools = [ usePatternFlyDocsTool, fetchDocsTool, - componentSchemasTool + componentSchemasTool, + getAvailableModulesTool, + getComponentSourceCode, + getReactUtilityClasses ], enableSigint = true }: { tools?: McpToolCreator[]; enableSigint?: boolean } = {}): Promise => { diff --git a/src/tool.getAvailableModules.ts b/src/tool.getAvailableModules.ts new file mode 100644 index 0000000..d391a6f --- /dev/null +++ b/src/tool.getAvailableModules.ts @@ -0,0 +1,58 @@ +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +import { type McpTool } from './server'; +import { memo } from './server.caching'; +import { getLocalModulesMap } from './utils.getLocalModulesMap'; + +export const getAvailableModulesTool = (): McpTool => { + const memoGetModulesMap = memo( + getLocalModulesMap + ); + + const callback = async (args: any = {}) => { + const { packageName } = args; + + if (typeof packageName !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: packageName (must be a string): ${packageName}` + ); + } + let modulesList : string[] = []; + + try { + // should be extended for other packages in the future + const modulesMap = await memoGetModulesMap(packageName); + + // no need to return paths, just the module names, reduce the context size + modulesList = Object.keys(modulesMap); + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Failed to retrieve available modules: ${error}` + ); + } + + return { + content: [ + { + type: 'text', + // Modules separated by semicolons to save context space + text: modulesList.join(';') + } + ] + }; + }; + + return [ + 'getAvailableModules', + { + description: 'Retrieves a list of available Patternfly react-core modules in the current environment.', + inputSchema: { + packageName: z.enum(['@patternfly/react-core', '@patternfly/react-icons', '@patternfly/react-table', '@patternfly/react-data-view', '@patternfly/react-component-groups']).describe('Name of the patternfly package to get modules for. For tables its always better to use the @patternfly/react-data-view package.').default('@patternfly/react-core') + } + }, + callback + ]; +}; diff --git a/src/tool.getComponentSourceCode.ts b/src/tool.getComponentSourceCode.ts new file mode 100644 index 0000000..bdd7929 --- /dev/null +++ b/src/tool.getComponentSourceCode.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +import { type McpTool } from './server'; +import { memo } from './server.caching'; +import { getLocalModulesMap } from './utils.getLocalModulesMap'; +import { verifyLocalPackage } from './utils.verifyLocalPackage'; +import { readFileAsync } from './utils.readFile'; + +export const getComponentSourceCode = (): McpTool => { + const memoGetModulesMap = memo( + getLocalModulesMap + ); + + const callback = async (args: any = {}) => { + const { componentName, packageName: packageNameArg } = args; + let fileContent: string; + const packageName = packageNameArg || '@patternfly/react-core'; + + if (typeof componentName !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: componentName (must be a string): ${componentName}` + ); + } + + // should be extended for other packages in the future + const status = await verifyLocalPackage(packageName); + + if (!status.exists) { + throw new McpError( + ErrorCode.InvalidParams, + `Package "${packageName}" not found locally. ${status.error ? status.error.message : ''}` + ); + } + const modulesMap = await memoGetModulesMap(packageName); + const componentPath = modulesMap[componentName]?.replace(/^dist\/dynamic/, 'src'); + + if (!componentPath) { + throw new McpError( + ErrorCode.InvalidParams, + `No valid path to "${componentName}" found in available modules.` + ); + } + + const componentDir = `${status.packageRoot}/${componentPath}`; + // TODO: We can also try to get directly to the component file first before checking re-exports + const indexFile = path.join(componentDir, 'index.ts'); + + const indexSource = await readFileAsync(`${componentDir}/index.ts`, 'utf-8'); + + if (!indexSource) { + throw new McpError( + ErrorCode.InternalError, + `Failed to read index.ts for component "${componentName}".` + ); + } + + const lines = indexSource.split('\n'); + // TODO: the modules map does not provide paths for everything, need to improve that + const searchPattern = new RegExp(`'\\./${componentName}';?$`); + const importPartial = lines.find(line => searchPattern.test(line)); + + if (typeof importPartial === 'undefined' || !importPartial) { + throw new McpError( + ErrorCode.InternalError, + `Failed to find source code for component "${componentName}".` + ); + } + + const importPath = importPartial.split('from')[1]?.trim().replace(/['";]/g, ''); + + if (!importPath) { + throw new McpError( + ErrorCode.InternalError, + `Failed to parse import path for component "${componentName}".` + ); + } + + const absolutePath = path.resolve(indexFile.replace('/index.ts', ''), importPath + '.ts'); + const absolutePathX = path.resolve(indexFile.replace('/index.ts', ''), importPath + '.tsx'); + + try { + fileContent = await readFileAsync(absolutePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + try { + fileContent = await readFileAsync(absolutePathX, 'utf-8'); + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Failed to read source code file for component "${componentName}": ${error}` + ); + } + } + + return { + content: [ + { + type: 'text', + text: fileContent + } + ] + }; + }; + + return [ + 'getComponentSourceCode', + { + description: 'Retrieve a source code of a specified Patternfly react-core module in the current environment.', + inputSchema: { + componentName: z.string().describe('Name of the PatternFly component (e.g., "Button", "Table")'), + packageName: z.enum(['@patternfly/react-core', '@patternfly/react-table', '@patternfly/react-data-view', '@patternfly/react-component-groups']).optional().describe('Name of the patternfly package to get component from. For tables its always better to use the @patternfly/react-data-view package.').default('@patternfly/react-core') + } + }, + callback + ]; +}; diff --git a/src/tool.getReactUtilityClasses.ts b/src/tool.getReactUtilityClasses.ts new file mode 100644 index 0000000..0614784 --- /dev/null +++ b/src/tool.getReactUtilityClasses.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { type McpTool } from './server'; +import { verifyLocalPackage } from './utils.verifyLocalPackage'; +import { readFileAsync } from './utils.readFile'; + +const utilityClassesMap: Record = { + Accessibility: 'css/utilities/Accessibility/accessibility.css', + Alignment: 'css/utilities/Alignment/alignment.css', + BackgroundColor: 'css/utilities/BackgroundColor/background-color.css', + BoxShadow: 'css/utilities/BoxShadow/box-shadow.css', + Display: 'css/utilities/Display/display.css', + Flex: 'css/utilities/Flex/flex.css', + Float: 'css/utilities/Float/float.css', + Sizing: 'css/utilities/Sizing/sizing.css', + Spacing: 'css/utilities/Spacing/spacing.css', + Text: 'css/utilities/Text/text.css' +}; + +const utilities = Object.keys(utilityClassesMap) as [string, ...string[]]; + +export const getReactUtilityClasses = (): McpTool => { + const callback = async (args: any = {}) => { + const { utilityName } = args; + let fileContent: string; + + if (typeof utilityName !== 'string' || !utilities.includes(utilityName)) { + throw new Error( + `Invalid or missing parameter: utilityName (must be one of: ${utilities.join(', ')}): ${utilityName}` + ); + } + const status = await verifyLocalPackage('@patternfly/react-styles'); + + if (!status.exists) { + throw new Error( + `Package "@patternfly/react-styles" not found locally. ${status.error ? status.error.message : ''}` + ); + } + + const root = status.packageRoot; + const utilityPath = utilityClassesMap[utilityName]; + const fullPath = `${root}/${utilityPath}`; + + try { + fileContent = await readFileAsync(fullPath, 'utf-8'); + } catch (error) { + throw new Error( + `Failed to read utility classes file for "${utilityName}" at path "${fullPath}". ${error instanceof Error ? error.message : ''}` + ); + } + + return { + content: [{ + type: 'text', + text: fileContent + }] + }; + }; + + return [ + 'getReactUtilityClasses', + { + description: 'Retrieves a list of available Patternfly react-styles utility classes in the current environment.', + inputSchema: { + utilityName: z.enum(utilities).describe('Name of a set of utility classes to retrieve from @patternfly/react-styles.') + } + }, + callback + ]; +}; diff --git a/src/utils.getLocalModulesMap.ts b/src/utils.getLocalModulesMap.ts new file mode 100644 index 0000000..7cddf16 --- /dev/null +++ b/src/utils.getLocalModulesMap.ts @@ -0,0 +1,21 @@ +import { readJsonFile } from './utils.readFile'; +import { verifyLocalPackage } from './utils.verifyLocalPackage'; + +export const getLocalModulesMap = async (packageName: string): Promise> => { + let modulesMap: Record = {}; + + const status = await verifyLocalPackage(packageName); + + if (!status.exists) { + throw new Error(`Package "${packageName}" not found locally. ${status.error ? status.error.message : ''}`); + } + + // exported map of module names to their paths + try { + modulesMap = await readJsonFile>(`${status.packageRoot}/dist/dynamic-modules.json`); + } catch (error) { + throw new Error(`Failed to import modules map from package "${packageName}": ${error}. Does the modules map exist?`); + } + + return modulesMap; +}; diff --git a/src/utils.moduleResolver.ts b/src/utils.moduleResolver.ts new file mode 100644 index 0000000..f5328ea --- /dev/null +++ b/src/utils.moduleResolver.ts @@ -0,0 +1,6 @@ +/** + * Utility for module resolution using import.meta.resolve + * This is separated into its own file to facilitate mocking in tests + */ + +export const resolveModule = (modulePath: string): string => import.meta.resolve(modulePath); diff --git a/src/utils.readFile.ts b/src/utils.readFile.ts new file mode 100644 index 0000000..c78b04d --- /dev/null +++ b/src/utils.readFile.ts @@ -0,0 +1,19 @@ +/** + * the bundler is stripping some parameters form dynamic imports which makes it impossible to import json files correctly + * WHen using const a = await import('path/to/file.json', { with: { type: 'json' } }); + * the resulted JS bundle is stripped to + * const a = await import('path/to/file.json'); + * + * This file is to workaround that issue by reading the file content directly + * */ + +import { promisify } from 'node:util'; +import { readFile } from 'node:fs'; + +export const readFileAsync = promisify(readFile); + +export const readJsonFile = async (filePath: string): Promise => { + const data = await readFileAsync(filePath, 'utf-8'); + + return JSON.parse(data) as T; +}; diff --git a/src/utils.verifyLocalPackage.ts b/src/utils.verifyLocalPackage.ts new file mode 100644 index 0000000..448ed92 --- /dev/null +++ b/src/utils.verifyLocalPackage.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; +import { readJsonFile } from './utils.readFile'; +import { resolveModule } from './utils.moduleResolver'; + +type VerifyLocalPackageStatus = { + exists: boolean; + version: string; + packageRoot: string; + error?: Error; +}; + +/** + * Verifies if a local package exists and retrieves its version and root path. + * + * @param packageName string - The name of the local package to verify. + * @returns {Promise} Verification status including existence, version, and root path. + */ +export const verifyLocalPackage = async (packageName: string) : Promise => { + const errorStatus : VerifyLocalPackageStatus = { + exists: false, + version: '', + packageRoot: '' + }; + + if (!packageName || typeof packageName !== 'string') { + return { ...errorStatus, error: new Error(`Invalid package name: ${packageName}`) }; + } + + // current working dir of agent + const projectRoot = process.cwd(); + + try { + // TODO: consider monorepo setup in the future + const pkgPath = resolveModule(`${projectRoot}/node_modules/${packageName}/package.json`); + const packageDir = path.dirname(pkgPath).replace(/^file:\/\//, ''); + const data = await readJsonFile<{ version: string }>(pkgPath.replace(/^file:\/\//, '')); + + return { + exists: true, + version: data.version || '', + packageRoot: packageDir + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + return { ...errorStatus, error: new Error(`Error resolving package "${packageName}": ${message}`) }; + } +}; diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index 7c34c9f..0802532 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -387,6 +387,9 @@ exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] "toolNames": [ "componentSchemas", "fetchDocs", + "getAvailableModules", + "getComponentSourceCode", + "getReactUtilityClasses", "usePatternFlyDocs", ], }