From c64b6c36f8013944c2862af56a637775481e7c88 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Sun, 4 Jan 2026 20:18:13 -0500 Subject: [PATCH 1/3] feat(resources): add server resources --- .../options.defaults.test.ts.snap | 6 +- .../__snapshots__/server.test.ts.snap | 170 +++++++++- .../__snapshots__/tool.fetchDocs.test.ts.snap | 75 ----- .../tool.searchPatternFlyDocs.test.ts.snap | 118 +++++++ src/__tests__/options.context.test.ts | 7 +- src/__tests__/server.test.ts | 1 + src/__tests__/tool.fetchDocs.test.ts | 105 ------ .../tool.searchPatternFlyDocs.test.ts | 94 ++++++ src/options.defaults.ts | 7 +- src/resource.patternFlyContext.ts | 58 ++++ src/resource.patternFlyDocsIndex.ts | 64 ++++ src/resource.patternFlyDocsTemplate.ts | 83 +++++ src/resource.patternFlySchemasIndex.ts | 41 +++ src/server.resources.ts | 32 ++ src/server.tools.ts | 2 +- src/server.ts | 70 +++- src/tool.componentSchemas.ts | 11 +- src/tool.fetchDocs.ts | 64 ---- src/tool.patternFlyDocs.ts | 32 +- src/tool.searchPatternFlyDocs.ts | 201 ++++++++++++ .../__snapshots__/httpTransport.test.ts.snap | 23 +- .../__snapshots__/stdioTransport.test.ts.snap | 309 ++++++++++-------- tests/httpTransport.test.ts | 112 ++++++- tests/stdioTransport.test.ts | 96 +++++- tests/utils/httpTransportClient.ts | 29 +- tests/utils/stdioTransportClient.ts | 29 +- 26 files changed, 1388 insertions(+), 451 deletions(-) delete mode 100644 src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap create mode 100644 src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap delete mode 100644 src/__tests__/tool.fetchDocs.test.ts create mode 100644 src/__tests__/tool.searchPatternFlyDocs.test.ts create mode 100644 src/resource.patternFlyContext.ts create mode 100644 src/resource.patternFlyDocsIndex.ts create mode 100644 src/resource.patternFlyDocsTemplate.ts create mode 100644 src/resource.patternFlySchemasIndex.ts create mode 100644 src/server.resources.ts delete mode 100644 src/tool.fetchDocs.ts create mode 100644 src/tool.searchPatternFlyDocs.ts diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 0cd3b19..847b562 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -54,6 +54,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "expire": 120000, }, }, + "resourceModules": [], "separator": " --- @@ -66,11 +67,6 @@ exports[`options defaults should return specific properties: defaults 1`] = ` }, }, "toolMemoOptions": { - "fetchDocs": { - "cacheErrors": false, - "cacheLimit": 15, - "expire": 60000, - }, "usePatternFlyDocs": { "cacheErrors": false, "cacheLimit": 10, diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index da84fa2..4961067 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -9,14 +9,29 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -44,14 +59,29 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -79,9 +109,24 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "test-server-4 server running on stdio transport", ], @@ -95,6 +140,7 @@ exports[`runServer should attempt to run server, create transport, connect, and }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -119,9 +165,24 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "test-server-7 server running on stdio transport", ], @@ -135,6 +196,7 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -154,9 +216,24 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "test-server-8 server running on stdio transport", ], @@ -170,6 +247,7 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -194,9 +272,24 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "Registered tool: loremIpsum", ], @@ -219,6 +312,7 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -245,9 +339,24 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "Registered tool: loremIpsum", ], @@ -279,6 +388,7 @@ exports[`runServer should attempt to run server, register multiple tools: diagno }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -306,9 +416,24 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "test-server-3 server running on stdio transport", ], @@ -322,6 +447,7 @@ exports[`runServer should attempt to run server, use custom options: diagnostics }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -346,14 +472,29 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -371,6 +512,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -384,7 +526,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno ], "registerTool": [ "usePatternFlyDocs", - "fetchDocs", + "searchPatternFlyDocs", "componentSchemas", ], } @@ -399,14 +541,29 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Server stats enabled.", ], + [ + "No external resources loaded.", + ], [ "No external tools loaded.", ], + [ + "Registered resource: patternfly-context", + ], + [ + "Registered resource: patternfly-docs-index", + ], + [ + "Registered resource: patternfly-docs-template", + ], + [ + "Registered resource: patternfly-schemas-index", + ], [ "Registered tool: usePatternFlyDocs", ], [ - "Registered tool: fetchDocs", + "Registered tool: searchPatternFlyDocs", ], [ "Registered tool: componentSchemas", @@ -424,6 +581,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn }, { "capabilities": { + "resources": {}, "tools": {}, }, }, @@ -437,7 +595,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn ], "registerTool": [ "usePatternFlyDocs", - "fetchDocs", + "searchPatternFlyDocs", "componentSchemas", ], } diff --git a/src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap deleted file mode 100644 index 63af756..0000000 --- a/src/__tests__/__snapshots__/tool.fetchDocs.test.ts.snap +++ /dev/null @@ -1,75 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`fetchDocsTool should have a consistent return structure: structure 1`] = ` -{ - "callback": [Function], - "name": "fetchDocs", - "schema": true, -} -`; - -exports[`fetchDocsTool, callback should parse parameters, default 1`] = ` -{ - "content": [ - { - "text": "components/button.md", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, multiple files 1`] = ` -{ - "content": [ - { - "text": "combined docs content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with empty files 1`] = ` -{ - "content": [ - { - "text": "trimmed content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with empty strings in a urlList 1`] = ` -{ - "content": [ - { - "text": "trimmed and empty content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with empty urlList 1`] = ` -{ - "content": [ - { - "text": "empty content", - "type": "text", - }, - ], -} -`; - -exports[`fetchDocsTool, callback should parse parameters, with invalid urlList 1`] = ` -{ - "content": [ - { - "text": "invalid path", - "type": "text", - }, - ], -} -`; diff --git a/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap new file mode 100644 index 0000000..d5085a0 --- /dev/null +++ b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`searchPatternFlyDocsTool should have a consistent return structure: structure 1`] = ` +{ + "callback": [Function], + "name": "searchPatternFlyDocs", + "schema": true, +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, default 1`] = ` +{ + "content": [ + { + "text": "Documentation URLs for "Button": + +1. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md +2. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md +3. https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Button/examples/Button.md + +Use the "usePatternFlyDocs" tool with these URLs to fetch content.", + "type": "text", + }, + ], +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with lower case componentName 1`] = ` +{ + "content": [ + { + "text": "Documentation URLs for "button": + +1. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md +2. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md +3. https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Button/examples/Button.md + +Use the "usePatternFlyDocs" tool with these URLs to fetch content.", + "type": "text", + }, + ], +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with made up componentName 1`] = ` +{ + "content": [ + { + "text": "No PatternFly components found matching "lorem ipsum dolor sit amet" +To browse all available components, read the "patternfly://schemas/index" resource.", + "type": "text", + }, + ], +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with multiple words 1`] = ` +{ + "content": [ + { + "text": "No PatternFly components found matching "Button Card Table" +To browse all available components, read the "patternfly://schemas/index" resource.", + "type": "text", + }, + ], +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with partial componentName 1`] = ` +{ + "content": [ + { + "text": "Documentation URLs for "ton": + +1. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md +2. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md +3. https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Button/examples/Button.md + +Use the "usePatternFlyDocs" tool with these URLs to fetch content.", + "type": "text", + }, + ], +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with trimmed componentName 1`] = ` +{ + "content": [ + { + "text": "Documentation URLs for " Button ": + +1. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md +2. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md +3. https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Button/examples/Button.md + +Use the "usePatternFlyDocs" tool with these URLs to fetch content.", + "type": "text", + }, + ], +} +`; + +exports[`searchPatternFlyDocsTool, callback should parse parameters, with upper case componentName 1`] = ` +{ + "content": [ + { + "text": "Documentation URLs for "BUTTON": + +1. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/design-guidelines/components/button/button.md +2. https://raw.githubusercontent.com/patternfly/patternfly-org/fb05713aba75998b5ecf5299ee3c1a259119bd74/packages/documentation-site/patternfly-docs/content/accessibility/button/button.md +3. https://raw.githubusercontent.com/patternfly/patternfly-react/refs/tags/v6.4.0/packages/react-core/src/components/Button/examples/Button.md + +Use the "usePatternFlyDocs" tool with these URLs to fetch content.", + "type": "text", + }, + ], +} +`; diff --git a/src/__tests__/options.context.test.ts b/src/__tests__/options.context.test.ts index 123fc08..01854ca 100644 --- a/src/__tests__/options.context.test.ts +++ b/src/__tests__/options.context.test.ts @@ -22,7 +22,7 @@ describe('setOptions', () => { expect(updatedOptions.logging.protocol).toBe(DEFAULT_OPTIONS.logging.protocol); expect(updatedOptions.resourceMemoOptions?.readFile?.expire).toBe(DEFAULT_OPTIONS.resourceMemoOptions?.readFile?.expire); - expect(updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.fetchDocs?.expire); + expect(updatedOptions.toolMemoOptions?.usePatternFlyDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.usePatternFlyDocs?.expire); expect(updatedOptions.pluginIsolation).toBe(DEFAULT_OPTIONS.pluginIsolation); }); @@ -35,8 +35,8 @@ describe('setOptions', () => { expect(typeof updatedOptions.resourceMemoOptions?.readFile?.expire).toBe('number'); expect(updatedOptions.resourceMemoOptions?.readFile?.expire).toBe(DEFAULT_OPTIONS.resourceMemoOptions?.readFile?.expire); - expect(typeof updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe('number'); - expect(updatedOptions.toolMemoOptions?.fetchDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.fetchDocs?.expire); + expect(typeof updatedOptions.toolMemoOptions?.usePatternFlyDocs?.expire).toBe('number'); + expect(updatedOptions.toolMemoOptions?.usePatternFlyDocs?.expire).toBe(DEFAULT_OPTIONS.toolMemoOptions?.usePatternFlyDocs?.expire); expect(typeof updatedOptions.pluginIsolation).toBe('string'); expect(updatedOptions.pluginIsolation).toBe(DEFAULT_OPTIONS.pluginIsolation); @@ -85,6 +85,7 @@ describe('tool creator options context', () => { // Mock server instance mockServer = { registerTool: jest.fn(), + registerResource: jest.fn(), connect: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) }; diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 1437f2f..9977f9f 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -34,6 +34,7 @@ describe('runServer', () => { // Mock server instance mockServer = { registerTool: jest.fn(), + registerResource: jest.fn(), connect: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) }; diff --git a/src/__tests__/tool.fetchDocs.test.ts b/src/__tests__/tool.fetchDocs.test.ts deleted file mode 100644 index c568614..0000000 --- a/src/__tests__/tool.fetchDocs.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { McpError } from '@modelcontextprotocol/sdk/types.js'; -import { fetchDocsTool } from '../tool.fetchDocs'; -import { processDocsFunction } from '../server.getResources'; -import { isPlainObject } from '../server.helpers'; - -// Mock dependencies -jest.mock('../server.getResources'); -jest.mock('../server.caching', () => ({ - memo: jest.fn(fn => fn) -})); - -const mockProcessDocs = processDocsFunction as jest.MockedFunction; - -describe('fetchDocsTool', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should have a consistent return structure', () => { - const tool = fetchDocsTool(); - - expect({ - name: tool[0], - schema: isPlainObject(tool[1]), - callback: tool[2] - }).toMatchSnapshot('structure'); - }); -}); - -describe('fetchDocsTool, callback', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it.each([ - { - description: 'default', - value: 'components/button.md', - urlList: ['components/button.md'] - }, - { - description: 'multiple files', - value: 'combined docs content', - urlList: ['components/button.md', 'components/card.md', 'components/table.md'] - }, - { - description: 'with empty files', - value: 'trimmed content', - urlList: ['components/button.md', '', ' ', 'components/card.md', 'components/table.md'] - }, - { - description: 'with empty urlList', - value: 'empty content', - urlList: [] - }, - { - description: 'with empty strings in a urlList', - value: 'trimmed and empty content', - urlList: ['', ' '] - }, - { - description: 'with invalid urlList', - value: 'invalid path', - urlList: ['invalid-url'] - } - ])('should parse parameters, $description', async ({ value, urlList }) => { - mockProcessDocs.mockResolvedValue(value); - const [_name, _schema, callback] = fetchDocsTool(); - const result = await callback({ urlList }); - - expect(mockProcessDocs).toHaveBeenCalledWith(urlList); - expect(result).toMatchSnapshot(); - }); - - it.each([ - { - description: 'with missing or undefined urlList', - error: 'Missing required parameter: urlList', - urlList: undefined - }, - { - description: 'with null urlList', - error: 'Missing required parameter: urlList', - urlList: null - }, - { - description: 'when urlList is not an array', - error: 'must be an array of strings', - urlList: 'not-an-array' - } - ])('should handle errors, $description', async ({ error, urlList }) => { - const [_name, _schema, callback] = fetchDocsTool(); - - await expect(callback({ urlList })).rejects.toThrow(McpError); - await expect(callback({ urlList })).rejects.toThrow(error); - }); - - it('should handle processing errors', async () => { - mockProcessDocs.mockRejectedValue(new Error('Network error')); - const [_name, _schema, callback] = fetchDocsTool(); - - await expect(callback({ urlList: ['missing.md'] })).rejects.toThrow(McpError); - await expect(callback({ urlList: ['missing.md'] })).rejects.toThrow('Failed to fetch documentation'); - }); -}); diff --git a/src/__tests__/tool.searchPatternFlyDocs.test.ts b/src/__tests__/tool.searchPatternFlyDocs.test.ts new file mode 100644 index 0000000..3fcb3a4 --- /dev/null +++ b/src/__tests__/tool.searchPatternFlyDocs.test.ts @@ -0,0 +1,94 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { searchPatternFlyDocsTool } from '../tool.searchPatternFlyDocs'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); + +describe('searchPatternFlyDocsTool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const tool = searchPatternFlyDocsTool(); + + expect({ + name: tool[0], + schema: isPlainObject(tool[1]), + callback: tool[2] + }).toMatchSnapshot('structure'); + }); +}); + +describe('searchPatternFlyDocsTool, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + searchQuery: 'Button' + }, + { + description: 'with trimmed componentName', + searchQuery: ' Button ' + }, + { + description: 'with lower case componentName', + searchQuery: 'button' + }, + { + description: 'with upper case componentName', + searchQuery: 'BUTTON' + }, + { + description: 'with partial componentName', + searchQuery: 'ton' + }, + { + description: 'with multiple words', + searchQuery: 'Button Card Table' + }, + { + description: 'with made up componentName', + searchQuery: 'lorem ipsum dolor sit amet' + } + ])('should parse parameters, $description', async ({ searchQuery }) => { + const [_name, _schema, callback] = searchPatternFlyDocsTool(); + const result = await callback({ searchQuery }); + + expect(result).toMatchSnapshot(); + }); + + it.each([ + { + description: 'with missing or undefined searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: undefined + }, + { + description: 'with null searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: null + }, + { + description: 'with empty searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: '' + }, + { + description: 'with non-string searchQuery', + error: 'Missing required parameter: searchQuery', + searchQuery: 123 + } + ])('should handle errors, $description', async ({ error, searchQuery }) => { + const [_name, _schema, callback] = searchPatternFlyDocsTool(); + + await expect(callback({ searchQuery })).rejects.toThrow(McpError); + await expect(callback({ searchQuery })).rejects.toThrow(error); + }); +}); diff --git a/src/options.defaults.ts b/src/options.defaults.ts index fb84e90..f74ab2a 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -64,6 +64,7 @@ interface DefaultOptions { pfExternalAccessibility: string; repoName: string | undefined; resourceMemoOptions: Partial; + resourceModules: unknown | unknown[]; separator: string; stats: StatsOptions; toolMemoOptions: Partial; @@ -238,11 +239,6 @@ const TOOL_MEMO_OPTIONS = { 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 } }; @@ -375,6 +371,7 @@ const DEFAULT_OPTIONS: DefaultOptions = { resourceMemoOptions: RESOURCE_MEMO_OPTIONS, repoName: basename(process.cwd() || '').trim(), stats: STATS_OPTIONS, + resourceModules: [], toolMemoOptions: TOOL_MEMO_OPTIONS, toolModules: [], separator: DEFAULT_SEPARATOR, diff --git a/src/resource.patternFlyContext.ts b/src/resource.patternFlyContext.ts new file mode 100644 index 0000000..fd306e4 --- /dev/null +++ b/src/resource.patternFlyContext.ts @@ -0,0 +1,58 @@ +import { type McpResource } from './server'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-context'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://context'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Design System Context', + description: 'Information about PatternFly design system and how to use this MCP server', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for context. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlyContextResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async () => { + const context = `PatternFly is an open-source design system for building consistent, accessible user interfaces. + +**What is PatternFly?** +PatternFly provides React components, design guidelines, and development tools for creating enterprise applications. It is used by Red Hat and other organizations to build consistent UIs with reusable components. + +**Key Features:** +- React component library with TypeScript support +- Design guidelines and accessibility standards +- JSON Schema validation for component props +- Comprehensive documentation and examples + +**PatternFly MCP Server:** +This MCP server provides tools to access PatternFly documentation, component schemas, and design guidelines. Use the available tools to fetch documentation, search for component information, and retrieve component prop definitions.`; + + return { + contents: [ + { + uri: 'patternfly://context', + mimeType: 'text/markdown', + text: context + } + ] + }; + } +]; + +export { patternFlyContextResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlyDocsIndex.ts b/src/resource.patternFlyDocsIndex.ts new file mode 100644 index 0000000..ea5825d --- /dev/null +++ b/src/resource.patternFlyDocsIndex.ts @@ -0,0 +1,64 @@ +import { COMPONENT_DOCS } from './docs.component'; +import { LAYOUT_DOCS } from './docs.layout'; +import { CHART_DOCS } from './docs.chart'; +import { getLocalDocs } from './docs.local'; +import { type McpResource } from './server'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-docs-index'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://docs/index'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Documentation Index', + description: 'A comprehensive list of PatternFly documentation links, organized by components, layouts, charts, and local files.', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for the documentation index. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlyDocsIndexResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async () => { + const allDocs = [ + '# PatternFly Documentation Index', + '', + '## Components', + ...COMPONENT_DOCS, + '', + '## Layouts', + ...LAYOUT_DOCS, + '', + '## Charts', + ...CHART_DOCS, + '', + '## Local Documentation', + ...getLocalDocs() + ].join('\n'); + + return { + contents: [ + { + uri: 'patternfly://docs/index', + mimeType: 'text/markdown', + text: allDocs + } + ] + }; + } +]; + +export { patternFlyDocsIndexResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts new file mode 100644 index 0000000..d13dfb7 --- /dev/null +++ b/src/resource.patternFlyDocsTemplate.ts @@ -0,0 +1,83 @@ +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { type McpResource } from './server'; +import { processDocsFunction } from './server.getResources'; +import { searchComponents } from './tool.searchPatternFlyDocs'; +import { getOptions } from './options.context'; +import { memo } from './server.caching'; + +/** + * Name of the resource template. + */ +const NAME = 'patternfly-docs-template'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = new ResourceTemplate('patternfly://docs/{name}', { list: undefined }); + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Documentation Page', + description: 'Retrieve specific PatternFly documentation by name or path', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for the documentation template. + * + * @param options - Global options + * @returns {McpResource} The resource definition tuple + */ +const patternFlyDocsTemplateResource = (options = getOptions()): McpResource => { + const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.usePatternFlyDocs); + + return [ + NAME, + URI_TEMPLATE, + CONFIG, + async (uri: URL, variables: Record) => { + const { name } = variables || {}; + + if (!name || typeof name !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: name must be a string: ${name}` + ); + } + + let result: string; + const { matchedUrls } = searchComponents(name); + + if (matchedUrls.length === 0) { + throw new McpError( + ErrorCode.InvalidParams, + `No documentation found for component: ${name}` + ); + } + + try { + result = await memoProcess(matchedUrls); + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Failed to fetch documentation: ${error}` + ); + } + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: result + } + ] + }; + } + ]; +}; + +export { patternFlyDocsTemplateResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlySchemasIndex.ts b/src/resource.patternFlySchemasIndex.ts new file mode 100644 index 0000000..4a8eeb7 --- /dev/null +++ b/src/resource.patternFlySchemasIndex.ts @@ -0,0 +1,41 @@ +import { componentNames } from './tool.searchPatternFlyDocs'; +import { type McpResource } from './server'; + +/** + * Name of the resource. + */ +const NAME = 'patternfly-schemas-index'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'patternfly://schemas/index'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'PatternFly Component Schemas Index', + description: 'A list of all PatternFly component names available for JSON Schema retrieval', + mimeType: 'text/markdown' +}; + +/** + * Resource creator for the component schemas index. + * + * @returns {McpResource} The resource definition tuple + */ +const patternFlySchemasIndexResource = (): McpResource => [ + NAME, + URI_TEMPLATE, + CONFIG, + async () => ({ + contents: [{ + uri: 'patternfly://schemas/index', + mimeType: 'text/markdown', + text: `# PatternFly Component Names Index\n\n${componentNames.join('\n')}` + }] + }) +]; + +export { patternFlySchemasIndexResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/server.resources.ts b/src/server.resources.ts new file mode 100644 index 0000000..d1dda0f --- /dev/null +++ b/src/server.resources.ts @@ -0,0 +1,32 @@ +import { type McpResourceCreator } from './server'; +import { type AppSession, type GlobalOptions } from './options'; +import { getOptions, getSessionOptions } from './options.context'; +import { log } from './logger'; + +/** + * Compose built-in resource creators. + * + * @note This is primarily a placeholder for future external resources. + * + * @param builtinCreators - Built-in tool creators + * @param {GlobalOptions} options - Global options. + * @param {AppSession} _sessionOptions - Session options. + * @returns {Promise} Promise array of tool creators + */ +const composeResources = async ( + builtinCreators: McpResourceCreator[], + { resourceModules }: GlobalOptions = getOptions(), + _sessionOptions: AppSession = getSessionOptions() +): Promise => { + const resourceCreators: McpResourceCreator[] = [...builtinCreators]; + + if (!Array.isArray(resourceModules) || resourceModules.length === 0) { + log.info('No external resources loaded.'); + + return resourceCreators; + } + + return resourceCreators; +}; + +export { composeResources }; diff --git a/src/server.tools.ts b/src/server.tools.ts index 1640c47..d4d61e0 100644 --- a/src/server.tools.ts +++ b/src/server.tools.ts @@ -524,7 +524,7 @@ const sendToolsHostShutdown = async ( }; /** - * Compose built-in creators with any externally loaded creators. + * Compose built-in tool creators with any externally loaded creators. * * - Node.js version policy: * - Node >= 22, external plugins are executed out-of-process via a Tools Host. diff --git a/src/server.ts b/src/server.ts index 0b874e5..51ec13d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,18 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer, type ResourceTemplate, type ResourceMetadata } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { usePatternFlyDocsTool } from './tool.patternFlyDocs'; -import { fetchDocsTool } from './tool.fetchDocs'; +import { searchPatternFlyDocsTool } from './tool.searchPatternFlyDocs'; import { componentSchemasTool } from './tool.componentSchemas'; +import { patternFlyContextResource } from './resource.patternFlyContext'; +import { patternFlyDocsIndexResource } from './resource.patternFlyDocsIndex'; +import { patternFlyDocsTemplateResource } from './resource.patternFlyDocsTemplate'; +import { patternFlySchemasIndexResource } from './resource.patternFlySchemasIndex'; import { startHttpTransport, type HttpServerHandle } from './server.http'; import { memo } from './server.caching'; import { log, type LogEvent } from './logger'; import { createServerLogger } from './server.logger'; import { composeTools, sendToolsHostShutdown } from './server.tools'; +import { composeResources } from './server.resources'; import { type GlobalOptions } from './options'; import { getOptions, @@ -43,6 +48,21 @@ type McpTool = [ */ type McpToolCreator = ((options?: GlobalOptions) => McpTool) & { toolName?: string }; +/** + * A resource registered with the MCP server. + */ +type McpResource = [ + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata, + handler: (...args: any[]) => any | Promise +]; + +/** + * A function that creates a resource registered with the MCP server. + */ +type McpResourceCreator = ((options?: GlobalOptions) => McpResource) & { resourceName?: string }; + /** * Server options. Equivalent to GlobalOptions. */ @@ -54,11 +74,13 @@ type ServerOptions = GlobalOptions; * @interface ServerSettings * * @property {McpToolCreator[]} [tools] - An optional array of tool creators used by the server. + * @property {McpResourceCreator[]} [resources] - An optional array of resource creators used by the server. * @property [enableSigint] - Indicates whether SIGINT signal handling is enabled. * @property [allowProcessExit] - Determines if the process is allowed to exit explicitly. */ interface ServerSettings { tools?: McpToolCreator[]; + resources?: McpResourceCreator[]; enableSigint?: boolean; allowProcessExit?: boolean; } @@ -117,10 +139,22 @@ interface ServerInstance { */ const builtinTools: McpToolCreator[] = [ usePatternFlyDocsTool, - fetchDocsTool, + searchPatternFlyDocsTool, componentSchemasTool ]; +/** + * Built-in resources. + * + * Array of built-in resources + */ +const builtinResources: McpResourceCreator[] = [ + patternFlyContextResource, + patternFlyDocsIndexResource, + patternFlyDocsTemplateResource, + patternFlySchemasIndexResource +]; + /** * Create and run the MCP server, register tools, and return a handle. * @@ -132,10 +166,12 @@ const builtinTools: McpToolCreator[] = [ * @param [settings.tools] - Built-in tools to register. * @param [settings.enableSigint] - Indicates whether SIGINT signal handling is enabled. * @param [settings.allowProcessExit] - Determines if the process is allowed to exit explicitly, useful for testing. + * @param settings.resources * @returns Server instance with `stop()`, `getStats()` `isRunning()`, and `onLog()` subscription. */ const runServer = async (options: ServerOptions = getOptions(), { tools = builtinTools, + resources = builtinResources, enableSigint = true, allowProcessExit = true }: ServerSettings = {}): Promise => { @@ -195,6 +231,7 @@ const runServer = async (options: ServerOptions = getOptions(), { { capabilities: { tools: {}, + resources: {}, ...(enableProtocolLogging ? { logging: {} } : {}) } } @@ -217,6 +254,9 @@ const runServer = async (options: ServerOptions = getOptions(), { log.info(`Server stats enabled.`); + // Compose resources after logging is set up. + const updatedResources = await composeResources(resources); + // Combine built-in tools with custom ones after logging is set up. const updatedTools = await composeTools(tools); @@ -238,6 +278,28 @@ const runServer = async (options: ServerOptions = getOptions(), { getStatsSetup = () => statsTracker.getStats(); } + updatedResources.forEach(resourceCreator => { + const [name, uri, config, callback] = resourceCreator(options); + + log.info(`Registered resource: ${name}`); + + server?.registerResource(name, uri as any, config, (...args: unknown[]) => + runWithSession(session, async () => + runWithOptions(options, async () => { + log.debug( + `Running resource "${name}"`, + `isArgs = ${args?.length > 0}` + ); + + const timedReport = stat.traffic(); + const resourceResult = await callback(...args); + + timedReport({ resource: name }); + + return resourceResult; + }))); + }); + updatedTools.forEach(toolCreator => { const [name, schema, callback] = toolCreator(options); // Do NOT normalize schemas here. This is by design and is a fallback check for malformed schemas. @@ -379,6 +441,8 @@ export { builtinTools, type McpTool, type McpToolCreator, + type McpResource, + type McpResourceCreator, type ServerInstance, type ServerLogEvent, type ServerOnLog, diff --git a/src/tool.componentSchemas.ts b/src/tool.componentSchemas.ts index 0186cc2..4e6d4db 100644 --- a/src/tool.componentSchemas.ts +++ b/src/tool.componentSchemas.ts @@ -1,10 +1,11 @@ import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { componentNames, getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; +import { getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { type McpTool } from './server'; import { getOptions } from './options.context'; import { memo } from './server.caching'; import { fuzzySearch } from './server.search'; +import { componentNames } from './tool.searchPatternFlyDocs'; /** * Derive the component schema type from @patternfly/patternfly-component-schemas @@ -12,18 +13,18 @@ import { fuzzySearch } from './server.search'; type ComponentSchema = Awaited>; /** - * componentSchemas tool function (tuple pattern) + * componentSchemas tool function * * Creates an MCP tool that retrieves JSON Schema for PatternFly React components. * Uses fuzzy search to handle typos and case variations, with related fallback suggestions. * * @param options - Optional configuration options (defaults to OPTIONS) - * @returns {McpTool} MCP tool tuple [name, schema, callback] + * @returns MCP tool tuple [name, schema, callback] */ 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?.usePatternFlyDocs ); const callback = async (args: any = {}) => { @@ -32,7 +33,7 @@ const componentSchemasTool = (options = getOptions()): McpTool => { if (typeof componentName !== 'string') { throw new McpError( ErrorCode.InvalidParams, - `Missing required parameter: componentName (must be a string): ${componentName}` + `Missing required parameter: componentName must be a string: ${componentName}` ); } diff --git a/src/tool.fetchDocs.ts b/src/tool.fetchDocs.ts deleted file mode 100644 index 2e2bea5..0000000 --- a/src/tool.fetchDocs.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from 'zod'; -import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { type McpTool } from './server'; -import { processDocsFunction } from './server.getResources'; -import { getOptions } from './options.context'; -import { memo } from './server.caching'; - -/** - * fetchDocs tool function (tuple pattern) - * - * @param options - */ -const fetchDocsTool = (options = getOptions()): McpTool => { - const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.fetchDocs); - - const callback = async (args: any = {}) => { - const { urlList } = args; - - if (!urlList || !Array.isArray(urlList)) { - throw new McpError( - ErrorCode.InvalidParams, - `Missing required parameter: urlList (must be an array of strings): ${urlList}` - ); - } - - let result: string; - - try { - result = await memoProcess(urlList); - } catch (error) { - throw new McpError( - ErrorCode.InternalError, - `Failed to fetch documentation: ${error}` - ); - } - - return { - content: [ - { - type: 'text', - text: result - } - ] - }; - }; - - return [ - 'fetchDocs', - { - description: 'Fetch documentation for one or more URLs extracted from previous tool calls responses. The URLs should be passed as an array in the "urlList" argument.', - inputSchema: { - urlList: z.array(z.string()).describe('The list of URLs to fetch documentation from') - } - }, - callback - ]; -}; - -/** - * A tool name, typically the first entry in the tuple. Used in logging and deduplication. - */ -fetchDocsTool.toolName = 'fetchDocs'; - -export { fetchDocsTool }; diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index ecb29b6..173f25c 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -1,19 +1,15 @@ -import { join } from 'node:path'; import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { type McpTool } from './server'; -import { COMPONENT_DOCS } from './docs.component'; -import { LAYOUT_DOCS } from './docs.layout'; -import { CHART_DOCS } from './docs.chart'; -import { getLocalDocs } from './docs.local'; import { getOptions } from './options.context'; import { processDocsFunction } from './server.getResources'; import { memo } from './server.caching'; /** - * usePatternFlyDocs tool function (tuple pattern) + * usePatternFlyDocs tool function * * @param options + * @returns MCP tool tuple [name, schema, callback] */ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.usePatternFlyDocs); @@ -24,7 +20,7 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { if (!urlList || !Array.isArray(urlList)) { throw new McpError( ErrorCode.InvalidParams, - `Missing required parameter: urlList (must be an array of strings): ${urlList}` + `Missing required parameter: urlList must be an array of strings: ${urlList}` ); } @@ -52,23 +48,15 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { return [ 'usePatternFlyDocs', { - description: `You must use this tool to answer any questions related to PatternFly components or documentation. + description: `Fetch documentation content for specific PatternFly components or layouts. - The description of the tool contains links to ${options.docsHost ? 'llms.txt' : '.md'} files or local file paths that the user has made available. + **Discovery**: + - To browse all available documentation, read the "patternfly://docs/index" resource. + - To browse all available components, read the "patternfly://schemas/index" resource. + - To find specific URLs by component name, use the "searchPatternFlyDocs" tool. - ${options.docsHost - ? `[@patternfly/react-core@6.0.0^](${join('react-core', '6.0.0', 'llms.txt')})` - : ` - ${COMPONENT_DOCS.join('\n')} - ${LAYOUT_DOCS.join('\n')} - ${CHART_DOCS.join('\n')} - ${getLocalDocs().join('\n')} - ` - } - - 1. Pick the most suitable URL from the above list, and use that as the "urlList" argument for this tool's execution, to get the docs content. If it's just one, let it be an array with one URL. - 2. Analyze the URLs listed in the ${options.docsHost ? 'llms.txt' : '.md'} file - 3. Then fetch specific documentation pages relevant to the user's question with the subsequent tool call.`, + **Usage**: + Provide a list of URLs discovered via the resource or search tool to retrieve their full markdown content.`, inputSchema: { urlList: z.array(z.string()).describe('The list of urls to fetch the documentation from') } diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts new file mode 100644 index 0000000..4fa447f --- /dev/null +++ b/src/tool.searchPatternFlyDocs.ts @@ -0,0 +1,201 @@ +import { z } from 'zod'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { componentNames as pfComponentNames } from '@patternfly/patternfly-component-schemas/json'; +import { type McpTool } from './server'; +import { COMPONENT_DOCS } from './docs.component'; +import { LAYOUT_DOCS } from './docs.layout'; +import { CHART_DOCS } from './docs.chart'; +import { getLocalDocs } from './docs.local'; +import { fuzzySearch } from './server.search'; +import { memo } from './server.caching'; +import { DEFAULT_OPTIONS } from './options.defaults'; + +/** + * List of component names to include in search results. + * + * @note The "table" component is manually added to the list because it's not currently included + * in the component schemas package. + */ +const componentNames = [...pfComponentNames, 'Table'].sort((a, b) => a.localeCompare(b)); + +/** + * Extract a component name from a documentation URL string + * + * @note This is reliant on the documentation URLs being in the accepted format. + * If the format changes, this will need to be updated. + * + * @example + * extractComponentName('[@patternfly/ComponentName - Type](URL)'); + * + * @param docUrl - Documentation URL string + * @returns ComponentName or `null` if not found + */ +const extractComponentName = (docUrl: string): string | null => { + const match = docUrl.match(/\[@patternfly\/([^\s-]+)/); + + return match && match[1] ? match[1] : null; +}; + +/** + * Extract a URL from a Markdown link + * + * @example + * extractUrl('[text](URL)'); + * + * @param docUrl + * @returns URL or original string if not a Markdown link + */ +const extractUrl = (docUrl: string): string => { + const match = docUrl.match(/]\(([^)]+)\)/); + + return match && match[1] ? match[1] : docUrl; +}; + +/** + * Build a map of component names relative to documentation URLs. + * + * @returns Map of component name -> array of URLs (Design Guidelines + Accessibility) + */ +const buildComponentToDocsMap = (): Map => { + const map = new Map(); + const allDocs = [...COMPONENT_DOCS, ...LAYOUT_DOCS, ...CHART_DOCS, ...getLocalDocs()]; + + for (const docUrl of allDocs) { + const componentName = extractComponentName(docUrl); + + if (componentName) { + const url = extractUrl(docUrl); + const existing = map.get(componentName) || []; + + map.set(componentName, [...existing, url]); + } + } + + return map; +}; + +/** + * Memoized version of buildComponentToDocsMap. Use default memo options. + */ +buildComponentToDocsMap.memo = memo(buildComponentToDocsMap, DEFAULT_OPTIONS.resourceMemoOptions.default); + +/** + * Search for PatternFly component documentation URLs using fuzzy search. + * + * @param searchQuery - Search query string + * @returns Object containing search results and matched URLs + */ +const searchComponents = (searchQuery: string) => { + const componentToDocsMap = buildComponentToDocsMap(); + + // Use fuzzy search to handle exact matches and variations + const searchResults = fuzzySearch(searchQuery, componentNames, { + maxDistance: 3, + maxResults: 10, + isFuzzyMatch: true, + deduplicateByNormalized: true + }); + + const matchedUrls: string[] = []; + const seenUrls = new Set(); + + for (const result of searchResults) { + const urls = componentToDocsMap.get(result.item) || []; + + for (const url of urls) { + if (!seenUrls.has(url)) { + matchedUrls.push(url); + seenUrls.add(url); + } + } + } + + return { + searchResults, + matchedUrls + }; +}; + +/** + * searchPatternFlyDocs tool function + * + * Searches for PatternFly component documentation URLs using fuzzy search. + * Returns URLs only (does not fetch content). Use usePatternFlyDocs to fetch the actual content. + * + * @returns MCP tool tuple [name, schema, callback] + */ +const searchPatternFlyDocsTool = (): McpTool => { + const callback = async (args: any = {}) => { + const { searchQuery } = args; + + if (!searchQuery || typeof searchQuery !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + `Missing required parameter: searchQuery must be a string: ${searchQuery}` + ); + } + + const { searchResults, matchedUrls } = searchComponents(searchQuery); + + if (searchResults.length === 0) { + return { + content: [{ + type: 'text', + text: [ + `No PatternFly components found matching "${searchQuery}"`, + 'To browse all available components, read the "patternfly://schemas/index" resource.' + ].join('\n') + }] + }; + } + + // For scenarios where no documentation URLs are available for a component, return a + // message with the first matched component and a list of similar components. + if (matchedUrls.length === 0) { + const componentList = searchResults + .slice(0, 5) + .map(result => result.item) + .join(', '); + + return { + content: [ + { + type: 'text', + text: [ + `Found components matching "${searchQuery}" but no documentation URLs are available. Matched components: ${componentList}`, + 'To browse all available documentation, read the "patternfly://docs/index" resource.' + ].join('\n') + } + ] + }; + } + + // Return the first 10 matched URLs as a formatted list + const urlListText = matchedUrls + .slice(0, 10) + .map((url, index) => `${index + 1}. ${url}`) + .join('\n'); + + return { + content: [{ + type: 'text', + text: `Documentation URLs for "${searchQuery}":\n\n${urlListText}\n\nUse the "usePatternFlyDocs" tool with these URLs to fetch content.` + }] + }; + }; + + return [ + 'searchPatternFlyDocs', + { + description: 'Search for PatternFly component documentation URLs. Returns URLs only (no content). Use "usePatternFlyDocs" to fetch the actual documentation.', + inputSchema: { + searchQuery: z.string().describe('Component name to search for (e.g., "button", "table")') + } + }, + callback + ]; +}; + +searchPatternFlyDocsTool.toolName = 'searchPatternFlyDocs'; + +export { searchPatternFlyDocsTool, searchComponents, componentNames }; diff --git a/tests/__snapshots__/httpTransport.test.ts.snap b/tests/__snapshots__/httpTransport.test.ts.snap index e7ac195..3a4c130 100644 --- a/tests/__snapshots__/httpTransport.test.ts.snap +++ b/tests/__snapshots__/httpTransport.test.ts.snap @@ -1,6 +1,19 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator with two local files 1`] = ` +exports[`Builtin resources, HTTP transport should expose expected resources and templates: resources 1`] = ` +{ + "resourceNames": [ + "patternfly://context", + "patternfly://docs/index", + "patternfly://schemas/index", + ], + "templateNames": [ + "patternfly://docs/{name}", + ], +} +`; + +exports[`Builtin tools, HTTP transport should concatenate headers and separator with two local files 1`] = ` "# Documentation from documentation/guidelines/README.md # PatternFly Guidelines @@ -108,7 +121,7 @@ You can find documentation on PatternFly's components at [PatternFly All compone " `; -exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator with two remote files 1`] = ` +exports[`Builtin tools, HTTP transport should concatenate headers and separator with two remote files 1`] = ` "# Documentation from https://www.patternfly.org/notARealPath/README.md # PatternFly Development Rules @@ -132,17 +145,17 @@ exports[`PatternFly MCP, HTTP Transport should concatenate headers and separator This is a test document for mocking remote HTTP requests." `; -exports[`PatternFly MCP, HTTP Transport should expose expected tools and stable shape 1`] = ` +exports[`Builtin tools, HTTP transport should expose expected tools and stable shape: tools 1`] = ` { "toolNames": [ "componentSchemas", - "fetchDocs", + "searchPatternFlyDocs", "usePatternFlyDocs", ], } `; -exports[`PatternFly MCP, HTTP Transport should initialize MCP session over HTTP 1`] = ` +exports[`Builtin tools, HTTP transport should initialize MCP session over HTTP 1`] = ` { "baseUrl": "http://127.0.0.1:5001", "name": "@patternfly/patternfly-mcp", diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index 482ce48..d8c3b99 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -1,5 +1,160 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`Builtin resources, STDIO should expose expected resources and templates 1`] = ` +{ + "resourceNames": [ + "patternfly://context", + "patternfly://docs/index", + "patternfly://schemas/index", + ], + "templateNames": [ + "patternfly://docs/{name}", + ], +} +`; + +exports[`Builtin tools, STDIO should concatenate headers and separator with two local files 1`] = ` +"# Documentation from documentation/guidelines/README.md + +# PatternFly Guidelines + +Core development rules for AI coders building PatternFly React applications. + +## Related Files + +- [**Component Rules**](./component-architecture.md) - Component structure requirements +- [**Styling Rules**](./styling-standards.md) - CSS and styling requirements +- [**Layout Rules**](../components/layout/README.md) - Page structure requirements + +## Essential Rules + +### Version Requirements + +- ✅ **ALWAYS use PatternFly v6** - Use \`pf-v6-\` prefixed classes only +- ❌ **NEVER use legacy versions** - No \`pf-v5-\`, \`pf-v4-\`, or \`pf-c-\` classes +- ✅ **Match component and CSS versions** - Ensure compatibility + +### Component Usage Rules + +- ✅ **Use PatternFly components first** - Before creating custom solutions +- ✅ **Compose components** - Build complex UIs by combining PatternFly components +- ❌ **Don't override component internals** - Use provided props and APIs + +### Tokenss +- ✅ **ALWAYS use PatternFly tokens** - Use \`pf-t-\` prefixed classes over \`pf-v6-\` classes (e.g., \`var(--pf-t--global--spacer--sm)\` not \`var(--pf-v6-global--spacer--sm)\`) + +### Text Components (v6+) +\`\`\`jsx +// ✅ Correct +import { Content } from '@patternfly/react-core'; +Title + +// ❌ Wrong - Don't use old Text components +Title +\`\`\` + +### Icon Usage +\`\`\`jsx +// ✅ Correct - Wrap with Icon component +import { Icon } from '@patternfly/react-core'; +import { UserIcon } from '@patternfly/react-icons'; + +\`\`\` + +### Styling Rules + +- ✅ **Use PatternFly utilities** - Before writing custom CSS +- ✅ **Use semantic design tokens** for custom CSS (e.g., \`var(--pf-t--global--text--color--regular)\`), not base tokens with numbers (e.g., \`--pf-t--global--text--color--100\`) or hardcoded values +- ❌ **Don't mix PatternFly versions** - Stick to v6 throughout + +### Documentation Requirements + +1. **Check [PatternFly.org](https://www.patternfly.org/) first** - Primary source for APIs +2. **Check the [PatternFly React GitHub repository](https://github.com/patternfly/patternfly-react)** for the latest source code, examples, and release notes +3. **Use "View Code" sections** - Copy working examples +4. **Reference version-specific docs** - Match your project's PatternFly version +5. **Provide context to AI** - Share links and code snippets when asking for help + +> For the most up-to-date documentation, use both the official docs and the source repositories. When using AI tools, encourage them to leverage context7 to fetch the latest documentation from these sources. + +### Accessibility Requirements + +- ✅ **WCAG 2.1 AA compliance** - All components must meet standards +- ✅ **Proper ARIA labels** - Use semantic markup and labels +- ✅ **Keyboard navigation** - Ensure full keyboard accessibility +- ✅ **Focus management** - Logical focus order and visible indicators + +## Quality Checklist + +- [ ] Uses PatternFly v6 classes only +- [ ] Components render correctly across browsers +- [ ] Responsive on mobile and desktop +- [ ] Keyboard navigation works +- [ ] Screen readers can access content +- [ ] No console errors or warnings +- [ ] Performance is acceptable + +## When Issues Occur + +1. **Check [PatternFly.org](https://www.patternfly.org/)** - Verify component API +2. **Inspect elements** - Use browser dev tools for PatternFly classes +3. **Search [GitHub issues](https://github.com/patternfly/patternfly-react/issues)** - Look for similar problems +4. **Provide context** - Share code snippets and error messages + +See [Common Issues](../troubleshooting/common-issues.md) for specific problems. + +--- + +# Documentation from documentation/components/README.md + +# PatternFly React Components + +You can find documentation on PatternFly's components at [PatternFly All components documentation](https://www.patternfly.org/components/all-components) + +## Specific info on Components + +- [AboutModal](https://www.patternfly.org/components/about-modal) +- [Accordion](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/components/accordion/accordion.md) +- [ActionList](https://www.patternfly.org/components/action-list) +- [Alert](https://www.patternfly.org/components/alert) +- [ApplicationLauncher](https://www.patternfly.org/components/application-launcher) +" +`; + +exports[`Builtin tools, STDIO should concatenate headers and separator with two remote files 1`] = ` +"# Documentation from http://127.0.0.1:5010/notARealPath/README.md + +# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration + +--- + +# Documentation from http://127.0.0.1:5010/notARealPath/AboutModal.md + +# Test Document + +This is a test document for mocking remote HTTP requests." +`; + +exports[`Builtin tools, STDIO should expose expected tools and stable shape 1`] = ` +{ + "toolNames": [ + "componentSchemas", + "searchPatternFlyDocs", + "usePatternFlyDocs", + ], +} +`; + exports[`Hosted mode, --docs-host should read llms-files and includes expected tokens 1`] = ` [ "# @patternfly/react-core 6.0.0", @@ -273,12 +428,22 @@ exports[`Logging should allow setting logging options, stderr 1`] = ` "[INFO]: Server logging enabled. ", "[INFO]: Server stats enabled. +", + "[INFO]: No external resources loaded. ", "[INFO]: No external tools loaded. +", + "[INFO]: Registered resource: patternfly-context +", + "[INFO]: Registered resource: patternfly-docs-index +", + "[INFO]: Registered resource: patternfly-docs-template +", + "[INFO]: Registered resource: patternfly-schemas-index ", "[INFO]: Registered tool: usePatternFlyDocs ", - "[INFO]: Registered tool: fetchDocs + "[INFO]: Registered tool: searchPatternFlyDocs ", "[INFO]: Registered tool: componentSchemas ", @@ -302,148 +467,6 @@ exports[`Logging should allow setting logging options, with mcp protocol 1`] = ` ] `; -exports[`PatternFly MCP, STDIO should concatenate headers and separator with two local files 1`] = ` -"# Documentation from documentation/guidelines/README.md - -# PatternFly Guidelines - -Core development rules for AI coders building PatternFly React applications. - -## Related Files - -- [**Component Rules**](./component-architecture.md) - Component structure requirements -- [**Styling Rules**](./styling-standards.md) - CSS and styling requirements -- [**Layout Rules**](../components/layout/README.md) - Page structure requirements - -## Essential Rules - -### Version Requirements - -- ✅ **ALWAYS use PatternFly v6** - Use \`pf-v6-\` prefixed classes only -- ❌ **NEVER use legacy versions** - No \`pf-v5-\`, \`pf-v4-\`, or \`pf-c-\` classes -- ✅ **Match component and CSS versions** - Ensure compatibility - -### Component Usage Rules - -- ✅ **Use PatternFly components first** - Before creating custom solutions -- ✅ **Compose components** - Build complex UIs by combining PatternFly components -- ❌ **Don't override component internals** - Use provided props and APIs - -### Tokenss -- ✅ **ALWAYS use PatternFly tokens** - Use \`pf-t-\` prefixed classes over \`pf-v6-\` classes (e.g., \`var(--pf-t--global--spacer--sm)\` not \`var(--pf-v6-global--spacer--sm)\`) - -### Text Components (v6+) -\`\`\`jsx -// ✅ Correct -import { Content } from '@patternfly/react-core'; -Title - -// ❌ Wrong - Don't use old Text components -Title -\`\`\` - -### Icon Usage -\`\`\`jsx -// ✅ Correct - Wrap with Icon component -import { Icon } from '@patternfly/react-core'; -import { UserIcon } from '@patternfly/react-icons'; - -\`\`\` - -### Styling Rules - -- ✅ **Use PatternFly utilities** - Before writing custom CSS -- ✅ **Use semantic design tokens** for custom CSS (e.g., \`var(--pf-t--global--text--color--regular)\`), not base tokens with numbers (e.g., \`--pf-t--global--text--color--100\`) or hardcoded values -- ❌ **Don't mix PatternFly versions** - Stick to v6 throughout - -### Documentation Requirements - -1. **Check [PatternFly.org](https://www.patternfly.org/) first** - Primary source for APIs -2. **Check the [PatternFly React GitHub repository](https://github.com/patternfly/patternfly-react)** for the latest source code, examples, and release notes -3. **Use "View Code" sections** - Copy working examples -4. **Reference version-specific docs** - Match your project's PatternFly version -5. **Provide context to AI** - Share links and code snippets when asking for help - -> For the most up-to-date documentation, use both the official docs and the source repositories. When using AI tools, encourage them to leverage context7 to fetch the latest documentation from these sources. - -### Accessibility Requirements - -- ✅ **WCAG 2.1 AA compliance** - All components must meet standards -- ✅ **Proper ARIA labels** - Use semantic markup and labels -- ✅ **Keyboard navigation** - Ensure full keyboard accessibility -- ✅ **Focus management** - Logical focus order and visible indicators - -## Quality Checklist - -- [ ] Uses PatternFly v6 classes only -- [ ] Components render correctly across browsers -- [ ] Responsive on mobile and desktop -- [ ] Keyboard navigation works -- [ ] Screen readers can access content -- [ ] No console errors or warnings -- [ ] Performance is acceptable - -## When Issues Occur - -1. **Check [PatternFly.org](https://www.patternfly.org/)** - Verify component API -2. **Inspect elements** - Use browser dev tools for PatternFly classes -3. **Search [GitHub issues](https://github.com/patternfly/patternfly-react/issues)** - Look for similar problems -4. **Provide context** - Share code snippets and error messages - -See [Common Issues](../troubleshooting/common-issues.md) for specific problems. - ---- - -# Documentation from documentation/components/README.md - -# PatternFly React Components - -You can find documentation on PatternFly's components at [PatternFly All components documentation](https://www.patternfly.org/components/all-components) - -## Specific info on Components - -- [AboutModal](https://www.patternfly.org/components/about-modal) -- [Accordion](https://raw.githubusercontent.com/patternfly/patternfly-org/refs/heads/main/packages/documentation-site/patternfly-docs/content/components/accordion/accordion.md) -- [ActionList](https://www.patternfly.org/components/action-list) -- [Alert](https://www.patternfly.org/components/alert) -- [ApplicationLauncher](https://www.patternfly.org/components/application-launcher) -" -`; - -exports[`PatternFly MCP, STDIO should concatenate headers and separator with two remote files 1`] = ` -"# Documentation from http://127.0.0.1:5010/notARealPath/README.md - -# PatternFly Development Rules - This is a generated offline fixture used by the MCP external URLs test. - - Essential rules and guidelines working with PatternFly applications. - - ## Quick Navigation - - ### 🚀 Setup & Environment - - **Setup Rules** - Project initialization requirements - - **Quick Start** - Essential setup steps - - **Environment Rules** - Development configuration - ---- - -# Documentation from http://127.0.0.1:5010/notARealPath/AboutModal.md - -# Test Document - -This is a test document for mocking remote HTTP requests." -`; - -exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] = ` -{ - "toolNames": [ - "componentSchemas", - "fetchDocs", - "usePatternFlyDocs", - ], -} -`; - exports[`Tools should interact with a tool, echo basic tool 1`] = ` { "args": { diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts index 4f14869..eccafa8 100644 --- a/tests/httpTransport.test.ts +++ b/tests/httpTransport.test.ts @@ -7,7 +7,7 @@ import { createMcpTool } from '../dist/index.js'; import { startServer, type HttpTransportClient, type RpcRequest } from './utils/httpTransportClient'; import { setupFetchMock } from './utils/fetchMock'; -describe('PatternFly MCP, HTTP Transport', () => { +describe('Builtin tools, HTTP transport', () => { let FETCH_MOCK: Awaited> | undefined; let CLIENT: HttpTransportClient | undefined; @@ -72,7 +72,7 @@ describe('PatternFly MCP, HTTP Transport', () => { const tools = response?.result?.tools || []; const toolNames = tools.map((tool: any) => tool.name).sort(); - expect({ toolNames }).toMatchSnapshot(); + expect({ toolNames }).toMatchSnapshot('tools'); }); it('should concatenate headers and separator with two local files', async () => { @@ -105,7 +105,7 @@ describe('PatternFly MCP, HTTP Transport', () => { id: 1, method: 'tools/call', params: { - name: 'fetchDocs', + name: 'usePatternFlyDocs', arguments: { urlList: [ 'https://www.patternfly.org/notARealPath/README.md', @@ -124,7 +124,111 @@ describe('PatternFly MCP, HTTP Transport', () => { }); }); -describe('Inline tools over HTTP', () => { +describe('Builtin resources, HTTP transport', () => { + let FETCH_MOCK: Awaited> | undefined; + let CLIENT: HttpTransportClient | undefined; + + beforeAll(async () => { + FETCH_MOCK = await setupFetchMock({ + routes: [ + { + url: /\/README\.md$/, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: `# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration` + }, + { + url: /.*button.*/i, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: '# Test Document\n\nThis is a test document for mocking remote HTTP requests.' + } + ], + excludePorts: [5002] + }); + + CLIENT = await startServer({ http: { port: 5002 }, logging: { level: 'debug', protocol: true } }); + }); + + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + CLIENT = undefined; + } + + if (FETCH_MOCK) { + await FETCH_MOCK.cleanup(); + } + }); + + it('should expose expected resources and templates', async () => { + const resources = await CLIENT?.send({ method: 'resources/list' }); + const updatedResources = resources?.result?.resources || []; + const resourceNames = updatedResources.map((resource: any) => resource.uri).sort(); + + const templates = await CLIENT?.send({ method: 'resources/templates/list' }); + const updatedTemplates = templates?.result?.resourceTemplates || []; + const templateNames = updatedTemplates.map((template: any) => template.uriTemplate).sort(); + + expect({ resourceNames, templateNames }).toMatchSnapshot('resources'); + }); + + it('should read the patternfly-context resource', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://context' } + }); + const content = response?.result.contents[0]; + + expect(content.text).toContain('PatternFly is an open-source design system'); + expect(content.mimeType).toBe('text/markdown'); + }); + + it('should read the patternfly-docs-index', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://docs/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://docs/index'); + expect(content.text).toContain('PatternFly Documentation Index'); + }); + + it('should read a doc through a template', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://docs/Button' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://docs/Button'); + expect(content.text).toContain('This is a test document for mocking remote HTTP requests'); + }); + + it('should read the patternfly-schemas-index', async () => { + const response = await CLIENT?.send({ + method: 'resources/read', + params: { uri: 'patternfly://schemas/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://schemas/index'); + expect(content.text).toContain('PatternFly Component Names Index'); + }); +}); + +describe('Inline tools, HTTP transport', () => { let CLIENT: HttpTransportClient | undefined; afterAll(async () => { diff --git a/tests/stdioTransport.test.ts b/tests/stdioTransport.test.ts index 88c5505..e5c5dd2 100644 --- a/tests/stdioTransport.test.ts +++ b/tests/stdioTransport.test.ts @@ -12,7 +12,7 @@ import { } from './utils/stdioTransportClient'; import { setupFetchMock } from './utils/fetchMock'; -describe('PatternFly MCP, STDIO', () => { +describe('Builtin tools, STDIO', () => { let FETCH_MOCK: Awaited> | undefined; let CLIENT: StdioTransportClient; let URL_MOCK: string; @@ -100,7 +100,7 @@ describe('PatternFly MCP, STDIO', () => { id: 1, method: 'tools/call', params: { - name: 'fetchDocs', + name: 'usePatternFlyDocs', arguments: { urlList: [ // URL_MOCK @@ -119,6 +119,98 @@ describe('PatternFly MCP, STDIO', () => { }); }); +describe('Builtin resources, STDIO', () => { + let FETCH_MOCK: Awaited> | undefined; + let CLIENT: StdioTransportClient; + + beforeAll(async () => { + FETCH_MOCK = await setupFetchMock({ + port: 5011, + routes: [ + { + url: /\/README\.md$/, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: `# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration` + }, + { + url: /.*\.md$/, + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: '# Test Document\n\nThis is a test document for mocking remote HTTP requests.' + } + ] + }); + + CLIENT = await startServer(); + }); + + afterAll(async () => { + if (CLIENT) { + await CLIENT.close(); + } + + if (FETCH_MOCK) { + await FETCH_MOCK.cleanup(); + } + }); + + it('should expose expected resources and templates', async () => { + const resources = await CLIENT.send({ method: 'resources/list' }); + const updatedResources = resources?.result?.resources || []; + const resourceNames = updatedResources.map((resource: any) => resource.uri).sort(); + + const templates = await CLIENT.send({ method: 'resources/templates/list' }); + const updatedTemplates = templates?.result?.resourceTemplates || []; + const templateNames = updatedTemplates.map((template: any) => template.uriTemplate).sort(); + + expect({ resourceNames, templateNames }).toMatchSnapshot(); + }); + + it('should read the patternfly-context resource', async () => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri: 'patternfly://context' } + }); + const content = response?.result.contents[0]; + + expect(content.text).toContain('PatternFly is an open-source design system'); + expect(content.mimeType).toBe('text/markdown'); + }); + + it('should read the patternfly-docs-index', async () => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri: 'patternfly://docs/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://docs/index'); + expect(content.text).toContain('PatternFly Documentation Index'); + }); + + it('should read the patternfly-schemas-index', async () => { + const response = await CLIENT.send({ + method: 'resources/read', + params: { uri: 'patternfly://schemas/index' } + }); + const content = response?.result.contents[0]; + + expect(content.uri).toBe('patternfly://schemas/index'); + expect(content.text).toContain('PatternFly Component Names Index'); + }); +}); + describe('Hosted mode, --docs-host', () => { let CLIENT: StdioTransportClient; diff --git a/tests/utils/httpTransportClient.ts b/tests/utils/httpTransportClient.ts index 9e29c18..79e143b 100644 --- a/tests/utils/httpTransportClient.ts +++ b/tests/utils/httpTransportClient.ts @@ -4,7 +4,15 @@ */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { ListToolsResultSchema, ResultSchema, LoggingMessageNotificationSchema, type LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { + ListToolsResultSchema, + ResultSchema, + ReadResourceResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + LoggingMessageNotificationSchema, + type LoggingLevel +} from '@modelcontextprotocol/sdk/types.js'; // @ts-ignore - dist/index.js isn't necessarily built yet, remember to build before running tests import { start, type PfMcpOptions, type PfMcpSettings, type ServerLogEvent } from '../../dist/index.js'; @@ -173,7 +181,24 @@ export const startServer = async ( sessionId: transport.sessionId, async send(request: { method: string; params?: any }): Promise { - // Use the SDK client's request method + if (request.method === 'resources/list') { + const result = await mcpClient.request(request, ListResourcesResultSchema); + + return { jsonrpc: '2.0', id: null, result: result as any }; + } + + if (request.method === 'resources/templates/list') { + const result = await mcpClient.request(request, ListResourceTemplatesResultSchema); + + return { jsonrpc: '2.0', id: null, result: result as any }; + } + + if (request.method === 'resources/read') { + const result = await mcpClient.request(request, ReadResourceResultSchema); + + return { jsonrpc: '2.0', id: null, result: result as any }; + } + // For tools/list, use the proper schema if (request.method === 'tools/list') { const result = await mcpClient.request(request, ListToolsResultSchema); diff --git a/tests/utils/stdioTransportClient.ts b/tests/utils/stdioTransportClient.ts index 91fe5e1..2fdfcf8 100644 --- a/tests/utils/stdioTransportClient.ts +++ b/tests/utils/stdioTransportClient.ts @@ -4,7 +4,14 @@ */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { ResultSchema, LoggingMessageNotificationSchema, type LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { + ResultSchema, + // ReadResourceResultSchema, + // ListResourcesResultSchema, + // ListResourceTemplatesResultSchema, + LoggingMessageNotificationSchema, + type LoggingLevel +} from '@modelcontextprotocol/sdk/types.js'; import { parseCliOptions } from '../../src/options'; export type { Request as RpcRequest } from '@modelcontextprotocol/sdk/types.js'; @@ -164,6 +171,26 @@ export const startServer = async ({ async send(request: { method: string; params?: any }, _opts?: { timeoutMs?: number }): Promise { try { // Use high-level SDK methods when available for better type safety + if (request.method === 'resources/list') { + const result = await mcpClient.listResources(request.params); + + return { jsonrpc: '2.0', id: null, result }; + } + + if (request.method === 'resources/templates/list') { + const result = await mcpClient.listResourceTemplates(request.params); + + return { jsonrpc: '2.0', id: null, result }; + } + + if (request.method === 'resources/read' && request.params?.uri) { + const result = await mcpClient.readResource({ + uri: request.params.uri + }); + + return { jsonrpc: '2.0', id: null, result }; + } + if (request.method === 'tools/list') { const result = await mcpClient.listTools(request.params); From 11e128ff83dedcae849e8f4c1a283faab9cc03b6 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 9 Jan 2026 14:52:25 -0500 Subject: [PATCH 2/3] docs: review update --- src/server.resources.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.resources.ts b/src/server.resources.ts index d1dda0f..41c640a 100644 --- a/src/server.resources.ts +++ b/src/server.resources.ts @@ -8,10 +8,10 @@ import { log } from './logger'; * * @note This is primarily a placeholder for future external resources. * - * @param builtinCreators - Built-in tool creators + * @param builtinCreators - Built-in resource creators * @param {GlobalOptions} options - Global options. * @param {AppSession} _sessionOptions - Session options. - * @returns {Promise} Promise array of tool creators + * @returns {Promise} Promise array of resource creators */ const composeResources = async ( builtinCreators: McpResourceCreator[], From 028bf39b76f659ce87541130431ba4444c93d07e Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 9 Jan 2026 14:55:14 -0500 Subject: [PATCH 3/3] test: base resource tests --- .../resource.patternFlyContext.test.ts.snap | 10 ++ .../resource.patternFlyDocsIndex.test.ts.snap | 10 ++ ...source.patternFlyDocsTemplate.test.ts.snap | 28 ++++ ...source.patternFlySchemasIndex.test.ts.snap | 10 ++ .../resource.patternFlyContext.test.ts | 38 ++++++ .../resource.patternFlyDocsIndex.test.ts | 47 +++++++ .../resource.patternFlyDocsTemplate.test.ts | 124 ++++++++++++++++++ .../resource.patternFlySchemasIndex.test.ts | 44 +++++++ 8 files changed, 311 insertions(+) create mode 100644 src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap create mode 100644 src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap create mode 100644 src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap create mode 100644 src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap create mode 100644 src/__tests__/resource.patternFlyContext.test.ts create mode 100644 src/__tests__/resource.patternFlyDocsIndex.test.ts create mode 100644 src/__tests__/resource.patternFlyDocsTemplate.test.ts create mode 100644 src/__tests__/resource.patternFlySchemasIndex.test.ts diff --git a/src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap new file mode 100644 index 0000000..e073eb4 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlyContext.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlyContextResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-context", + "uri": "patternfly://context", +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap new file mode 100644 index 0000000..ad9fd09 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlyDocsIndexResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-docs-index", + "uri": "patternfly://docs/index", +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap new file mode 100644 index 0000000..5ed0afb --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlyDocsTemplateResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-docs-template", + "uri": ResourceTemplate { + "_callbacks": { + "list": undefined, + }, + "_uriTemplate": UriTemplate { + "parts": [ + "patternfly://docs/", + { + "exploded": false, + "name": "name", + "names": [ + "name", + ], + "operator": "", + }, + ], + "template": "patternfly://docs/{name}", + }, + }, +} +`; diff --git a/src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap b/src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap new file mode 100644 index 0000000..26bd6c3 --- /dev/null +++ b/src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`patternFlySchemasIndexResource should have a consistent return structure: structure 1`] = ` +{ + "config": true, + "handler": [Function], + "name": "patternfly-schemas-index", + "uri": "patternfly://schemas/index", +} +`; diff --git a/src/__tests__/resource.patternFlyContext.test.ts b/src/__tests__/resource.patternFlyContext.test.ts new file mode 100644 index 0000000..3aa93f2 --- /dev/null +++ b/src/__tests__/resource.patternFlyContext.test.ts @@ -0,0 +1,38 @@ +import { patternFlyContextResource } from '../resource.patternFlyContext'; +import { isPlainObject } from '../server.helpers'; + +describe('patternFlyContextResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlyContextResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlyContextResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + args: [] + } + ])('should return context content, $description', async ({ args }) => { + const [_name, _uri, _config, callback] = patternFlyContextResource(); + const result = await callback(...args); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + }); +}); diff --git a/src/__tests__/resource.patternFlyDocsIndex.test.ts b/src/__tests__/resource.patternFlyDocsIndex.test.ts new file mode 100644 index 0000000..c2bd15c --- /dev/null +++ b/src/__tests__/resource.patternFlyDocsIndex.test.ts @@ -0,0 +1,47 @@ +import { patternFlyDocsIndexResource } from '../resource.patternFlyDocsIndex'; +import { getLocalDocs } from '../docs.local'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../docs.local'); + +const mockGetLocalDocs = getLocalDocs as jest.MockedFunction; + +describe('patternFlyDocsIndexResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetLocalDocs.mockReturnValue(['[@patternfly/react-guidelines](./guidelines/README.md)']); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlyDocsIndexResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlyDocsIndexResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetLocalDocs.mockReturnValue(['[@patternfly/react-guidelines](./guidelines/README.md)']); + }); + + it.each([ + { + description: 'default', + args: [] + } + ])('should return context content, $description', async ({ args }) => { + const [_name, _uri, _config, callback] = patternFlyDocsIndexResource(); + const result = await callback(...args); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + expect(result.contents[0].text).toContain('[@patternfly/react-guidelines](./guidelines/README.md)'); + }); +}); diff --git a/src/__tests__/resource.patternFlyDocsTemplate.test.ts b/src/__tests__/resource.patternFlyDocsTemplate.test.ts new file mode 100644 index 0000000..dd10699 --- /dev/null +++ b/src/__tests__/resource.patternFlyDocsTemplate.test.ts @@ -0,0 +1,124 @@ +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { patternFlyDocsTemplateResource } from '../resource.patternFlyDocsTemplate'; +import { processDocsFunction } from '../server.getResources'; +import { searchComponents } from '../tool.searchPatternFlyDocs'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../server.getResources'); +jest.mock('../tool.searchPatternFlyDocs'); +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); +jest.mock('../options.context', () => ({ + getOptions: jest.fn(() => ({})) +})); + +const mockProcessDocs = processDocsFunction as jest.MockedFunction; +const mockSearchComponents = searchComponents as jest.MockedFunction; + +describe('patternFlyDocsTemplateResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlyDocsTemplateResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlyDocsTemplateResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + name: 'Button', + matchedUrls: ['components/button.md'], + result: 'Button documentation content' + }, + { + description: 'with multiple matched URLs', + name: 'Card', + matchedUrls: ['components/card.md', 'components/card-examples.md'], + result: 'Card documentation content' + }, + { + description: 'with trimmed name', + name: ' Table ', + matchedUrls: ['components/table.md'], + result: 'Table documentation content' + }, + { + description: 'with lower case name', + name: 'button', + matchedUrls: ['components/button.md'], + result: 'Button documentation content' + } + ])('should parse parameters and return documentation, $description', async ({ name, matchedUrls, result: mockResult }) => { + mockSearchComponents.mockReturnValue({ matchedUrls, searchResults: [] }); + mockProcessDocs.mockResolvedValue(mockResult); + + const [_name, _uri, _config, callback] = patternFlyDocsTemplateResource(); + const uri = new URL('patternfly://docs/Button'); + const variables = { name }; + const result = await callback(uri, variables); + + expect(mockSearchComponents).toHaveBeenCalledWith(name); + expect(mockProcessDocs).toHaveBeenCalledWith(matchedUrls); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + expect(result.contents[0].text).toContain(mockResult); + }); + + it.each([ + { + description: 'with missing or undefined name', + error: 'Missing required parameter: name must be a string', + variables: {} + }, + { + description: 'with null name', + error: 'Missing required parameter: name must be a string', + variables: { name: null } + }, + { + description: 'with empty name', + error: 'Missing required parameter: name must be a string', + variables: { name: '' } + }, + { + description: 'with non-string name', + error: 'Missing required parameter: name must be a string', + variables: { name: 123 } + } + ])('should handle variable errors, $description', async ({ error, variables }) => { + const [_name, _uri, _config, callback] = patternFlyDocsTemplateResource(); + const uri = new URL('patternfly://docs/test'); + + await expect(callback(uri, variables)).rejects.toThrow(McpError); + await expect(callback(uri, variables)).rejects.toThrow(error); + }); + + it('should handle documentation loading errors', async () => { + mockSearchComponents.mockReturnValue({ matchedUrls: ['components/button.md'], searchResults: [] }); + mockProcessDocs.mockRejectedValue(new Error('File not found')); + + const [_name, _uri, _config, handler] = patternFlyDocsTemplateResource(); + const uri = new URL('patternfly://docs/Button'); + const variables = { name: 'Button' }; + + await expect(handler(uri, variables)).rejects.toThrow(McpError); + await expect(handler(uri, variables)).rejects.toThrow('Failed to fetch documentation'); + }); +}); diff --git a/src/__tests__/resource.patternFlySchemasIndex.test.ts b/src/__tests__/resource.patternFlySchemasIndex.test.ts new file mode 100644 index 0000000..504efa0 --- /dev/null +++ b/src/__tests__/resource.patternFlySchemasIndex.test.ts @@ -0,0 +1,44 @@ +import { patternFlySchemasIndexResource } from '../resource.patternFlySchemasIndex'; +import { isPlainObject } from '../server.helpers'; + +// Mock dependencies +jest.mock('../tool.searchPatternFlyDocs', () => ({ + componentNames: ['Button', 'Card', 'Table'] +})); + +describe('patternFlySchemasIndexResource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a consistent return structure', () => { + const resource = patternFlySchemasIndexResource(); + + expect({ + name: resource[0], + uri: resource[1], + config: isPlainObject(resource[2]), + handler: resource[3] + }).toMatchSnapshot('structure'); + }); +}); + +describe('patternFlySchemasIndexResource, callback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: 'default', + args: [] + } + ])('should return component schemas index, $description', async ({ args }) => { + const [_name, _uri, _config, callback] = patternFlySchemasIndexResource(); + const result = await callback(...args); + + expect(result.contents).toBeDefined(); + expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); + expect(result.contents[0].text).toContain('# PatternFly Component Names Index'); + }); +});