diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 2163e1be..b236f491 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -2,6 +2,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` { + "apiBaseUrl": "https://staging.patternfly.org", "contextPath": "/", "contextUrl": "file:///", "docsPath": "/documentation", diff --git a/src/__tests__/__snapshots__/tool.componentSchemas.test.ts.snap b/src/__tests__/__snapshots__/tool.componentSchemas.test.ts.snap index b4dd365f..236895df 100644 --- a/src/__tests__/__snapshots__/tool.componentSchemas.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.componentSchemas.test.ts.snap @@ -12,37 +12,8 @@ exports[`componentSchemasTool, callback should parse parameters, default 1`] = ` { "content": [ { - "text": "{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": "Button Props", - "description": "Props for the Button component", - "properties": { - "variant": { - "type": "string", - "enum": [ - "primary", - "secondary" - ] - }, - "size": { - "type": "string", - "enum": [ - "sm", - "md", - "lg" - ] - }, - "children": { - "type": "string", - "description": "Content rendered inside the button" - } - }, - "required": [ - "children" - ], - "additionalProperties": false -}", + "text": "| Prop | Type | +|---|---|", "type": "text", }, ], @@ -53,37 +24,8 @@ exports[`componentSchemasTool, callback should parse parameters, with lower case { "content": [ { - "text": "{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": "Button Props", - "description": "Props for the Button component", - "properties": { - "variant": { - "type": "string", - "enum": [ - "primary", - "secondary" - ] - }, - "size": { - "type": "string", - "enum": [ - "sm", - "md", - "lg" - ] - }, - "children": { - "type": "string", - "description": "Content rendered inside the button" - } - }, - "required": [ - "children" - ], - "additionalProperties": false -}", + "text": "| Prop | Type | +|---|---|", "type": "text", }, ], @@ -94,37 +36,8 @@ exports[`componentSchemasTool, callback should parse parameters, with trimmed co { "content": [ { - "text": "{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": "Button Props", - "description": "Props for the Button component", - "properties": { - "variant": { - "type": "string", - "enum": [ - "primary", - "secondary" - ] - }, - "size": { - "type": "string", - "enum": [ - "sm", - "md", - "lg" - ] - }, - "children": { - "type": "string", - "description": "Content rendered inside the button" - } - }, - "required": [ - "children" - ], - "additionalProperties": false -}", + "text": "| Prop | Type | +|---|---|", "type": "text", }, ], @@ -135,37 +48,8 @@ exports[`componentSchemasTool, callback should parse parameters, with upper case { "content": [ { - "text": "{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": "Button Props", - "description": "Props for the Button component", - "properties": { - "variant": { - "type": "string", - "enum": [ - "primary", - "secondary" - ] - }, - "size": { - "type": "string", - "enum": [ - "sm", - "md", - "lg" - ] - }, - "children": { - "type": "string", - "description": "Content rendered inside the button" - } - }, - "required": [ - "children" - ], - "additionalProperties": false -}", + "text": "| Prop | Type | +|---|---|", "type": "text", }, ], diff --git a/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap index e64429e5..d6fd50e9 100644 --- a/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.searchPatternFlyDocs.test.ts.snap @@ -10,11 +10,11 @@ exports[`searchPatternFlyDocsTool should have a consistent return structure: str exports[`searchPatternFlyDocsTool, callback should parse parameters, default: search 1`] = `"# Search results for "Button", 1 matches found:"`; -exports[`searchPatternFlyDocsTool, callback should parse parameters, with "*" searchQuery all: search 1`] = `"# Search results for "all components", 8 matches found:"`; +exports[`searchPatternFlyDocsTool, callback should parse parameters, with "*" searchQuery all: search 1`] = `"# Search results for "all components", 4 matches found:"`; -exports[`searchPatternFlyDocsTool, callback should parse parameters, with "all" searchQuery all: search 1`] = `"# Search results for "all components", 8 matches found:"`; +exports[`searchPatternFlyDocsTool, callback should parse parameters, with "all" searchQuery all: search 1`] = `"# Search results for "all components", 4 matches found:"`; -exports[`searchPatternFlyDocsTool, callback should parse parameters, with empty searchQuery all: search 1`] = `"# Search results for "all components", 8 matches found:"`; +exports[`searchPatternFlyDocsTool, callback should parse parameters, with empty searchQuery all: search 1`] = `"# Search results for "all components", 4 matches found:"`; exports[`searchPatternFlyDocsTool, callback should parse parameters, with lower case componentName: search 1`] = `"# Search results for "button", 1 matches found:"`; @@ -22,7 +22,7 @@ exports[`searchPatternFlyDocsTool, callback should parse parameters, with made u exports[`searchPatternFlyDocsTool, callback should parse parameters, with multiple words: search 1`] = `"# Search results for "Button Card Table", 3 matches found:"`; -exports[`searchPatternFlyDocsTool, callback should parse parameters, with partial componentName: search 1`] = `"# Search results for "ton", 2 matches found:"`; +exports[`searchPatternFlyDocsTool, callback should parse parameters, with partial componentName: search 1`] = `"# Search results for "ton", 1 matches found:"`; exports[`searchPatternFlyDocsTool, callback should parse parameters, with trimmed componentName: search 1`] = `"# Search results for " Button ", 1 matches found:"`; diff --git a/src/__tests__/resource.patternFlyDocsIndex.test.ts b/src/__tests__/resource.patternFlyDocsIndex.test.ts index c2bd15ca..f77c1f21 100644 --- a/src/__tests__/resource.patternFlyDocsIndex.test.ts +++ b/src/__tests__/resource.patternFlyDocsIndex.test.ts @@ -2,8 +2,39 @@ import { patternFlyDocsIndexResource } from '../resource.patternFlyDocsIndex'; import { getLocalDocs } from '../docs.local'; import { isPlainObject } from '../server.helpers'; -// Mock dependencies jest.mock('../docs.local'); +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); + +jest.mock('../api.client', () => ({ + getComponentList: Object.assign( + jest.fn(async () => ['Alert', 'Button', 'Card']), + { memo: jest.fn(async () => ['Alert', 'Button', 'Card']) } + ), + getComponentInfo: Object.assign( + jest.fn(async (name: string) => ({ + name, + section: 'components', + page: name.toLowerCase(), + tabs: ['react'], + hasProps: true, + hasCss: false, + exampleCount: 0 + })), + { + memo: jest.fn(async (name: string) => ({ + name, + section: 'components', + page: name.toLowerCase(), + tabs: ['react'], + hasProps: true, + hasCss: false, + exampleCount: 0 + })) + } + ) +})); const mockGetLocalDocs = getLocalDocs as jest.MockedFunction; @@ -43,5 +74,8 @@ describe('patternFlyDocsIndexResource, callback', () => { 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)'); + expect(result.contents[0].text).toContain('Alert'); + expect(result.contents[0].text).toContain('Button'); + expect(result.contents[0].text).toContain('Card'); }); }); diff --git a/src/__tests__/resource.patternFlyDocsTemplate.test.ts b/src/__tests__/resource.patternFlyDocsTemplate.test.ts index 4ba9c449..6ca16865 100644 --- a/src/__tests__/resource.patternFlyDocsTemplate.test.ts +++ b/src/__tests__/resource.patternFlyDocsTemplate.test.ts @@ -1,11 +1,10 @@ import { McpError } from '@modelcontextprotocol/sdk/types.js'; import { patternFlyDocsTemplateResource } from '../resource.patternFlyDocsTemplate'; -import { processDocsFunction } from '../server.getResources'; +import { fetchComponentData } from '../api.fetcher'; import { searchComponents } from '../tool.searchPatternFlyDocs'; import { isPlainObject } from '../server.helpers'; -// Mock dependencies -jest.mock('../server.getResources'); +jest.mock('../api.fetcher'); jest.mock('../tool.searchPatternFlyDocs'); jest.mock('../server.caching', () => ({ memo: jest.fn(fn => fn) @@ -14,7 +13,7 @@ jest.mock('../options.context', () => ({ getOptions: jest.fn(() => ({})) })); -const mockProcessDocs = processDocsFunction as jest.MockedFunction; +const mockFetchComponentData = fetchComponentData as jest.MockedFunction; const mockSearchComponents = searchComponents as jest.MockedFunction; describe('patternFlyDocsTemplateResource', () => { @@ -43,47 +42,29 @@ describe('patternFlyDocsTemplateResource, callback', () => { { description: 'default', name: 'Button', - urls: ['components/button.md'], - result: 'Button documentation content' - }, - { - description: 'with multiple matched URLs', - name: 'Card', - urls: ['components/card.md', 'components/card-examples.md'], - result: 'Card documentation content' + docs: '# Button documentation content' }, { description: 'with trimmed name', name: ' Table ', - urls: ['components/table.md'], - result: 'Table documentation content' + docs: '# Table documentation content' }, { description: 'with lower case name', name: 'button', - urls: ['components/button.md'], - result: 'Button documentation content' + docs: '# Button documentation content' } - ])('should parse parameters and return documentation, $description', async ({ name, urls, result: mockResult }) => { - mockSearchComponents.mockReturnValue({ - isSearchWildCardAll: false, - firstExactMatch: undefined, - exactMatches: [{ urls } as any], - searchResults: [] - }); - mockProcessDocs.mockResolvedValue([{ content: mockResult }] as any); + ])('should parse parameters and return documentation, $description', async ({ name, docs }) => { + mockFetchComponentData.mockResolvedValue({ name: name.trim(), info: {} as any, docs }); 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(urls); - expect(result.contents).toBeDefined(); expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); - expect(result.contents[0].text).toContain(mockResult); + expect(result.contents[0].text).toContain(docs); }); it.each([ @@ -115,14 +96,14 @@ describe('patternFlyDocsTemplateResource, callback', () => { await expect(callback(uri, variables)).rejects.toThrow(error); }); - it('should handle documentation loading errors', async () => { - mockSearchComponents.mockReturnValue({ + it('should handle documentation not found errors', async () => { + mockFetchComponentData.mockResolvedValue(undefined); + mockSearchComponents.mockResolvedValue({ isSearchWildCardAll: false, firstExactMatch: undefined, exactMatches: [], searchResults: [] }); - mockProcessDocs.mockRejectedValue(new Error('File not found')); const [_name, _uri, _config, handler] = patternFlyDocsTemplateResource(); const uri = new URL('patternfly://docs/Button'); diff --git a/src/__tests__/resource.patternFlySchemasIndex.test.ts b/src/__tests__/resource.patternFlySchemasIndex.test.ts index 504efa0e..9e02a86b 100644 --- a/src/__tests__/resource.patternFlySchemasIndex.test.ts +++ b/src/__tests__/resource.patternFlySchemasIndex.test.ts @@ -1,9 +1,37 @@ import { patternFlySchemasIndexResource } from '../resource.patternFlySchemasIndex'; import { isPlainObject } from '../server.helpers'; -// Mock dependencies -jest.mock('../tool.searchPatternFlyDocs', () => ({ - componentNames: ['Button', 'Card', 'Table'] +jest.mock('../server.caching', () => ({ + memo: jest.fn(fn => fn) +})); + +jest.mock('../api.client', () => ({ + getComponentList: Object.assign( + jest.fn(async () => ['Alert', 'Button', 'Card', 'Table']), + { memo: jest.fn(async () => ['Alert', 'Button', 'Card', 'Table']) } + ), + getComponentInfo: Object.assign( + jest.fn(async (name: string) => ({ + name, + section: 'components', + page: name.toLowerCase(), + tabs: ['react'], + hasProps: name !== 'Card', + hasCss: false, + exampleCount: 0 + })), + { + memo: jest.fn(async (name: string) => ({ + name, + section: 'components', + page: name.toLowerCase(), + tabs: ['react'], + hasProps: name !== 'Card', + hasCss: false, + exampleCount: 0 + })) + } + ) })); describe('patternFlySchemasIndexResource', () => { @@ -28,17 +56,16 @@ describe('patternFlySchemasIndexResource, callback', () => { jest.clearAllMocks(); }); - it.each([ - { - description: 'default', - args: [] - } - ])('should return component schemas index, $description', async ({ args }) => { + it('should return only components with props', async () => { const [_name, _uri, _config, callback] = patternFlySchemasIndexResource(); - const result = await callback(...args); + const result = await callback(); expect(result.contents).toBeDefined(); expect(Object.keys(result.contents[0])).toEqual(['uri', 'mimeType', 'text']); - expect(result.contents[0].text).toContain('# PatternFly Component Names Index'); + expect(result.contents[0].text).toContain('# PatternFly Component Schemas Index'); + expect(result.contents[0].text).toContain('Alert'); + expect(result.contents[0].text).toContain('Button'); + expect(result.contents[0].text).not.toContain('Card'); + expect(result.contents[0].text).toContain('Table'); }); }); diff --git a/src/__tests__/resource.patternFlySchemasTemplate.test.ts b/src/__tests__/resource.patternFlySchemasTemplate.test.ts index 8af3abaf..25d99a75 100644 --- a/src/__tests__/resource.patternFlySchemasTemplate.test.ts +++ b/src/__tests__/resource.patternFlySchemasTemplate.test.ts @@ -1,12 +1,11 @@ import { McpError } from '@modelcontextprotocol/sdk/types.js'; -import { getComponentSchema } from '../tool.patternFlyDocs'; import { patternFlySchemasTemplateResource } from '../resource.patternFlySchemasTemplate'; +import { fetchComponentData } from '../api.fetcher'; import { searchComponents } from '../tool.searchPatternFlyDocs'; import { isPlainObject } from '../server.helpers'; -// Mock dependencies +jest.mock('../api.fetcher'); jest.mock('../tool.searchPatternFlyDocs'); -jest.mock('../tool.patternFlyDocs'); jest.mock('../server.caching', () => ({ memo: jest.fn(fn => fn) })); @@ -14,7 +13,7 @@ jest.mock('../options.context', () => ({ getOptions: jest.fn(() => ({})) })); -const mockGetComponentSchema = getComponentSchema as jest.MockedFunction; +const mockFetchComponentData = fetchComponentData as jest.MockedFunction; const mockSearchComponents = searchComponents as jest.MockedFunction; describe('patternFlySchemasTemplateResource', () => { @@ -68,14 +67,32 @@ describe('patternFlySchemasTemplateResource, callback', () => { await expect(callback(uri, variables)).rejects.toThrow(error); }); - it('should handle missing exact match and missing schema errors', async () => { - mockSearchComponents.mockReturnValue({ + it('should return props when component found', async () => { + mockFetchComponentData.mockResolvedValue({ + name: 'Button', + info: {} as any, + props: '| Prop | Type | Default |\n|---|---|---|\n| variant | string | primary |' + }); + + const [_name, _uri, _config, callback] = patternFlySchemasTemplateResource(); + const uri = new URL('patternfly://schemas/Button'); + const variables = { name: 'Button' }; + const result = await callback(uri, variables); + + expect(result.contents).toBeDefined(); + expect(result.contents[0].mimeType).toBe('text/markdown'); + expect(result.contents[0].text).toContain('# Props for Button'); + expect(result.contents[0].text).toContain('| Prop | Type | Default |'); + }); + + it('should handle component not found', async () => { + mockFetchComponentData.mockResolvedValue(undefined); + mockSearchComponents.mockResolvedValue({ isSearchWildCardAll: false, firstExactMatch: undefined, exactMatches: [], searchResults: [] }); - mockGetComponentSchema.mockReturnValue(undefined as any); const [_name, _uri, _config, handler] = patternFlySchemasTemplateResource(); const uri = new URL('patternfly://schemas/DolorSitAmet'); @@ -85,20 +102,23 @@ describe('patternFlySchemasTemplateResource, callback', () => { await expect(handler(uri, variables)).rejects.toThrow('Component "DolorSitAmet" not found'); }); - it('should handle exact match but missing schema errors', async () => { - mockSearchComponents.mockReturnValue({ + it('should handle component found but props not available', async () => { + mockFetchComponentData.mockResolvedValue({ + name: 'Button', + info: {} as any + }); + mockSearchComponents.mockResolvedValue({ isSearchWildCardAll: false, firstExactMatch: undefined, - exactMatches: [{ item: 'Button', urls: [] } as any], + exactMatches: [], searchResults: [] }); - mockGetComponentSchema.mockReturnValue(undefined as any); const [_name, _uri, _config, handler] = patternFlySchemasTemplateResource(); - const uri = new URL('patternfly://schemas/DolorSitAmet'); + const uri = new URL('patternfly://schemas/Button'); const variables = { name: 'Button' }; await expect(handler(uri, variables)).rejects.toThrow(McpError); - await expect(handler(uri, variables)).rejects.toThrow('Component "Button" found'); + await expect(handler(uri, variables)).rejects.toThrow('Component "Button" found but prop schema not available'); }); }); diff --git a/src/__tests__/tool.componentSchemas.test.ts b/src/__tests__/tool.componentSchemas.test.ts index 9d173cd4..449716f8 100644 --- a/src/__tests__/tool.componentSchemas.test.ts +++ b/src/__tests__/tool.componentSchemas.test.ts @@ -1,12 +1,23 @@ import { McpError } from '@modelcontextprotocol/sdk/types.js'; import { componentSchemasTool } from '../tool.componentSchemas'; +import { fetchComponentData } from '../api.fetcher'; import { isPlainObject } from '../server.helpers'; -// Mock dependencies jest.mock('../server.caching', () => ({ memo: jest.fn(fn => fn) })); +jest.mock('../api.client', () => ({ + getComponentList: Object.assign( + jest.fn(async () => ['Alert', 'Button', 'Card', 'Table']), + { memo: jest.fn(async () => ['Alert', 'Button', 'Card', 'Table']) } + ) +})); + +jest.mock('../api.fetcher'); + +const mockFetchComponentData = fetchComponentData as jest.MockedFunction; + describe('componentSchemasTool', () => { beforeEach(() => { jest.clearAllMocks(); @@ -46,6 +57,12 @@ describe('componentSchemasTool, callback', () => { componentName: 'BUTTON' } ])('should parse parameters, $description', async ({ componentName }) => { + mockFetchComponentData.mockResolvedValue({ + name: 'Button', + info: {} as any, + props: '| Prop | Type |\n|---|---|' + }); + const [_name, _schema, callback] = componentSchemasTool(); const result = await callback({ componentName }); diff --git a/src/__tests__/tool.patternFlyDocs.test.ts b/src/__tests__/tool.patternFlyDocs.test.ts index d73523a8..f1616155 100644 --- a/src/__tests__/tool.patternFlyDocs.test.ts +++ b/src/__tests__/tool.patternFlyDocs.test.ts @@ -1,15 +1,17 @@ import { McpError } from '@modelcontextprotocol/sdk/types.js'; import { usePatternFlyDocsTool } from '../tool.patternFlyDocs'; -import { processDocsFunction } from '../server.getResources'; +import { fetchComponentData } from '../api.fetcher'; +import { searchComponents } from '../tool.searchPatternFlyDocs'; import { isPlainObject } from '../server.helpers'; -// Mock dependencies -jest.mock('../server.getResources'); +jest.mock('../api.fetcher'); +jest.mock('../tool.searchPatternFlyDocs'); jest.mock('../server.caching', () => ({ memo: jest.fn(fn => fn) })); -const mockProcessDocs = processDocsFunction as jest.MockedFunction; +const mockFetchComponentData = fetchComponentData as jest.MockedFunction; +const mockSearchComponents = searchComponents as jest.MockedFunction; describe('usePatternFlyDocsTool', () => { beforeEach(() => { @@ -34,84 +36,89 @@ describe('usePatternFlyDocsTool, callback', () => { it.each([ { - description: 'default', - value: 'components/button.md', - urlList: ['components/button.md'] + description: 'with docs only', + name: 'Button', + data: { name: 'Button', info: {} as any, docs: '# Button docs' } }, { - description: 'multiple files', - value: 'combined docs content', - urlList: ['components/button.md', 'components/card.md', 'components/table.md'] + description: 'with docs and props', + name: 'Alert', + data: { name: 'Alert', info: {} as any, docs: '# Alert docs', props: '| Prop | Type |\n|---|---|' } }, { - description: 'with invalid urlList', - value: 'invalid path', - urlList: ['invalid-url'] + description: 'with docs, props, and examples', + name: 'Card', + data: { name: 'Card', info: {} as any, docs: '# Card docs', props: '| Prop | Type |', examples: ['### Example: Basic\n\n```tsx\ncode\n```'] } }, { - description: 'with name', - value: 'button content', - name: 'button' + description: 'with all data types', + name: 'Table', + data: { name: 'Table', info: {} as any, docs: '# Table docs', props: '| Prop | Type |', examples: ['example'], css: '| Variable | Value |' } } - ])('should parse parameters, $description', async ({ value, urlList, name }) => { - mockProcessDocs.mockResolvedValue([{ content: value }] as any); + ])('should return documentation, $description', async ({ name, data }) => { + mockFetchComponentData.mockResolvedValue(data); const [_name, _schema, callback] = usePatternFlyDocsTool(); - const result = await callback({ urlList, name }); + const result = await callback({ name }); - expect(mockProcessDocs).toHaveBeenCalledTimes(1); expect(result.content[0].text).toBeDefined(); - expect(result.content[0].text.startsWith('# Documentation from')).toBe(true); + expect(result.content[0].text).toContain(name); + }); + + it('should suggest alternatives when component not found', async () => { + mockFetchComponentData.mockResolvedValue(undefined); + mockSearchComponents.mockResolvedValue({ + isSearchWildCardAll: false, + firstExactMatch: undefined, + exactMatches: [], + searchResults: [{ item: 'Button', matchType: 'fuzzy', distance: 2 } as any] + }); + + const [_name, _schema, callback] = usePatternFlyDocsTool(); + + await expect(callback({ name: 'Buttn' })).rejects.toThrow(McpError); + await expect(callback({ name: 'Buttn' })).rejects.toThrow('Component "Buttn" not found'); }); it.each([ { - description: 'with missing or undefined urlList', - error: 'Provide either a string', - urlList: undefined - }, - { - description: 'with null urlList', - error: 'Provide either a string', - urlList: null + description: 'with missing or undefined name', + error: 'Provide a string "name"', + name: undefined }, { - description: 'when urlList is not an array', - error: 'Provide either a string', - urlList: 'not-an-array' + description: 'with null name', + error: 'Provide a string "name"', + name: null }, { - description: 'with empty files', - error: 'Provide either a string', - urlList: ['components/button.md', '', ' ', 'components/card.md', 'components/table.md'] + description: 'with empty name', + error: 'Provide a string "name"', + name: ' ' }, { - description: 'with empty urlList', - error: 'Provide either a string', - urlList: [] - }, - { - description: 'with empty strings in a urlList', - error: 'Provide either a string', - urlList: ['', ' '] - }, - { - description: 'with both urlList and name', - error: 'Provide either a string', - urlList: ['components/button.md'], - name: 'lorem ipsum' + description: 'with non-string name', + error: 'Provide a string "name"', + name: 123 } - ])('should handle errors, $description', async ({ error, urlList, name }) => { + ])('should handle errors, $description', async ({ error, name }) => { + const [_name, _schema, callback] = usePatternFlyDocsTool(); + + await expect(callback({ name })).rejects.toThrow(McpError); + await expect(callback({ name })).rejects.toThrow(error); + }); + + it('should handle patternfly:// URI input', async () => { const [_name, _schema, callback] = usePatternFlyDocsTool(); - await expect(callback({ urlList, name })).rejects.toThrow(McpError); - await expect(callback({ urlList, name })).rejects.toThrow(error); + await expect(callback({ name: 'patternfly://docs/Button' })).rejects.toThrow(McpError); + await expect(callback({ name: 'patternfly://docs/Button' })).rejects.toThrow('Direct "patternfly://" URIs are not supported'); }); - it('should handle processing errors', async () => { - mockProcessDocs.mockRejectedValue(new Error('File not found')); + it('should return message when component found but no content available', async () => { + mockFetchComponentData.mockResolvedValue({ name: 'Empty', info: {} as any }); const [_name, _schema, callback] = usePatternFlyDocsTool(); + const result = await callback({ name: 'Empty' }); - await expect(callback({ urlList: ['missing.md'] })).rejects.toThrow(McpError); - await expect(callback({ urlList: ['missing.md'] })).rejects.toThrow('Failed to fetch documentation'); + expect(result.content[0].text).toContain('no documentation content is available'); }); }); diff --git a/src/__tests__/tool.searchPatternFlyDocs.test.ts b/src/__tests__/tool.searchPatternFlyDocs.test.ts index a4c2e1e4..8793792e 100644 --- a/src/__tests__/tool.searchPatternFlyDocs.test.ts +++ b/src/__tests__/tool.searchPatternFlyDocs.test.ts @@ -2,11 +2,39 @@ 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) })); +jest.mock('../api.client', () => ({ + getComponentList: Object.assign( + jest.fn(async () => ['Alert', 'Button', 'Card', 'Table']), + { memo: jest.fn(async () => ['Alert', 'Button', 'Card', 'Table']) } + ), + getComponentInfo: Object.assign( + jest.fn(async (name: string) => ({ + name, + section: 'components', + page: name.toLowerCase(), + tabs: ['react'], + hasProps: true, + hasCss: false, + exampleCount: 2 + })), + { + memo: jest.fn(async (name: string) => ({ + name, + section: 'components', + page: name.toLowerCase(), + tabs: ['react'], + hasProps: true, + hasCss: false, + exampleCount: 2 + })) + } + ) +})); + describe('searchPatternFlyDocsTool', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/api.client.ts b/src/api.client.ts new file mode 100644 index 00000000..f4146c81 --- /dev/null +++ b/src/api.client.ts @@ -0,0 +1,108 @@ +import { type ComponentIndex, type ComponentEntry, type ResolvedComponentInfo } from './api.types'; +import { getOptions } from './options.context'; +import { memo } from './server.caching'; +import { DEFAULT_OPTIONS } from './options.defaults'; +import { log } from './logger'; +import { buildFallbackIndex } from './api.fallback'; + +/** + * Fetch the component index from the doc-core API. + * + * @param options + * @note This is a lightweight static file (~14KB) served from Cloudflare's edge network, + * prerendered at build time. It only changes when doc-core deploys. + */ +const fetchComponentIndex = async (options = getOptions()): Promise => { + const baseUrl = options.apiBaseUrl; + const url = `${baseUrl}/api/component-index.json`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.xhrFetch.timeoutMs); + + timeout.unref(); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch component index from ${url}: ${response.status} ${response.statusText}`); + } + + const data: ComponentIndex = await response.json(); + + if (!data.version || !data.components) { + throw new Error(`Invalid component index shape from ${url}: missing "version" or "components"`); + } + + log.info(`Loaded component index: ${Object.keys(data.components).length} components (version: ${data.version})`); + + return data; + } catch (error) { + log.warn( + `PatternFly doc-core API error at "${baseUrl}": ${error instanceof Error ? error.message : error}. ` + + `Building fallback index from patternfly.org sitemap. ` + + `Component search and metadata will work, but documentation ` + + `content will be unavailable until the API is reachable.` + ); + + return buildFallbackIndex(options); + } finally { + clearTimeout(timeout); + } +}; + +fetchComponentIndex.memo = memo(fetchComponentIndex, { + cacheLimit: 1, + expire: 10 * 60 * 1000, + cacheErrors: false +}); + +const getComponentList = async (options = getOptions()): Promise => { + const index = await fetchComponentIndex.memo(options); + + return Object.keys(index.components).sort((a, b) => a.localeCompare(b)); +}; + +getComponentList.memo = memo(getComponentList, { + cacheLimit: 1, + expire: 10 * 60 * 1000, + cacheErrors: false +}); + +const getComponentInfo = async (name: string, options = getOptions()): Promise => { + const index = await fetchComponentIndex.memo(options); + const entry: ComponentEntry | undefined = index.components[name]; + + if (!entry) { + return undefined; + } + + return { + name, + ...entry + }; +}; + +getComponentInfo.memo = memo(getComponentInfo, DEFAULT_OPTIONS.toolMemoOptions.searchPatternFlyDocs); + +const getApiVersion = async (options = getOptions()): Promise => { + const index = await fetchComponentIndex.memo(options); + + return index.version; +}; + +getApiVersion.memo = memo(getApiVersion, { + cacheLimit: 1, + expire: 10 * 60 * 1000, + cacheErrors: false +}); + +export { + fetchComponentIndex, + getComponentList, + getComponentInfo, + getApiVersion +}; diff --git a/src/api.fallback.ts b/src/api.fallback.ts new file mode 100644 index 00000000..9bafb519 --- /dev/null +++ b/src/api.fallback.ts @@ -0,0 +1,148 @@ +import { componentNames as schemaComponentNames } from '@patternfly/patternfly-component-schemas/json'; +import { type ComponentEntry, type ComponentIndex } from './api.types'; +import { getOptions } from './options.context'; +import { log } from './logger'; + +const SITEMAP_URL = 'https://www.patternfly.org/sitemap.xml'; + +const toPascalCase = (slug: string): string => + slug + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + +const schemaNameSet = new Set(schemaComponentNames.map(name => name.toLowerCase())); + +const SECTION_PATTERNS: { section: string; pattern: RegExp }[] = [ + { section: 'components', pattern: /\/components\/(?:[^/]+\/)*([^/]+)\/?$/ }, + { section: 'layouts', pattern: /\/layouts\/([^/]+)\/?$/ }, + { section: 'charts', pattern: /\/charts\/([^/]+)\/?$/ } +]; + +const TAB_SUFFIXES = new Set([ + 'html', + 'html-demos', + 'html-deprecated', + 'react-demos', + 'react-deprecated', + 'react-templates', + 'design-guidelines', + 'accessibility', + 'all-components', + 'about-layouts', + 'about-charts', + 'ECharts' +]); + +const SKIP_PAGES = new Set([ + 'all-components', + 'about-layouts', + 'about-charts', + 'custom-menus', + 'options-menu' +]); + +const extractUrls = (xml: string): string[] => { + const urls: string[] = []; + const regex = /([^<]+)<\/loc>/g; + let match; + + while ((match = regex.exec(xml)) !== null) { + if (match[1]) { + urls.push(match[1]); + } + } + + return urls; +}; + +const extractComponents = ( + urls: string[] +): Map => { + const seen = new Map(); + + for (const url of urls) { + const path = new URL(url).pathname; + + for (const { section, pattern } of SECTION_PATTERNS) { + const match = path.match(pattern); + + if (!match) { + continue; + } + + const slug = match[1]; + + if (!slug || TAB_SUFFIXES.has(slug) || SKIP_PAGES.has(slug)) { + continue; + } + + const name = toPascalCase(slug); + + if (!seen.has(name)) { + seen.set(name, { section, page: slug }); + } + } + } + + return seen; +}; + +/** + * Build a ComponentIndex by fetching the patternfly.org sitemap and + * cross-referencing with @patternfly/patternfly-component-schemas. + * + * Only called when the primary doc-core API is unreachable. + * + * @param options + */ +const buildFallbackIndex = async ( + options = getOptions() +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.xhrFetch.timeoutMs + ); + + timeout.unref(); + + try { + const response = await fetch(SITEMAP_URL, { signal: controller.signal }); + + if (!response.ok) { + throw new Error( + `Failed to fetch sitemap: ${response.status} ${response.statusText}` + ); + } + + const xml = await response.text(); + const urls = extractUrls(xml); + const componentMap = extractComponents(urls); + + const components: Record = {}; + + for (const [name, { section, page }] of [...componentMap.entries()].sort( + ([a], [b]) => a.localeCompare(b) + )) { + components[name] = { + section, + page, + tabs: ['react'], + hasProps: schemaNameSet.has(name.toLowerCase()), + hasCss: false, + exampleCount: 1 + }; + } + + log.info( + `Built fallback index from sitemap: ${Object.keys(components).length} components` + ); + + return { version: 'fallback', components }; + } finally { + clearTimeout(timeout); + } +}; + +export { buildFallbackIndex }; diff --git a/src/api.fetcher.ts b/src/api.fetcher.ts new file mode 100644 index 00000000..57e0cfad --- /dev/null +++ b/src/api.fetcher.ts @@ -0,0 +1,243 @@ +import { type ResolvedComponentInfo, type DocIncludeType } from './api.types'; +import { getApiVersion, getComponentInfo } from './api.client'; +import { transformDocs, transformProps, transformCss } from './api.transforms'; +import { getOptions } from './options.context'; +import { memo } from './server.caching'; +import { DEFAULT_OPTIONS } from './options.defaults'; +import { log } from './logger'; + +interface FetchedComponentData { + name: string; + info: ResolvedComponentInfo; + docs?: string; + props?: string; + examples?: string[]; + css?: string; +} + +const fetchApiEndpoint = async ( + url: string, + accept = 'text/plain, text/markdown, */*', + options = getOptions() +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.xhrFetch.timeoutMs); + + timeout.unref(); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: accept } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${url} → ${response.status} ${response.statusText}`); + } + + return await response.text(); + } finally { + clearTimeout(timeout); + } +}; + +fetchApiEndpoint.memo = memo(fetchApiEndpoint, DEFAULT_OPTIONS.resourceMemoOptions.fetchUrl); + +const buildComponentBaseUrl = (baseUrl: string, version: string, info: ResolvedComponentInfo): string => + `${baseUrl}/api/${version}/${info.section}/${info.page}`; + +/** + * @param baseUrl + * @param version + * @param info + * @note Prefers the "react" tab when available, falls back to first tab. + */ +const fetchComponentDocs = async (baseUrl: string, version: string, info: ResolvedComponentInfo): Promise => { + const tab = info.tabs.includes('react') ? 'react' : info.tabs[0]; + + if (!tab) { + return undefined; + } + + const url = `${buildComponentBaseUrl(baseUrl, version, info)}/${tab}/text`; + + try { + const raw = await fetchApiEndpoint.memo(url); + + return transformDocs(raw, info.name); + } catch (error) { + log.warn(`Failed to fetch docs for ${info.name}: ${error}`); + + return undefined; + } +}; + +const fetchComponentProps = async (baseUrl: string, version: string, info: ResolvedComponentInfo): Promise => { + if (!info.hasProps) { + return undefined; + } + + const url = `${buildComponentBaseUrl(baseUrl, version, info)}/props`; + + try { + const raw = await fetchApiEndpoint.memo(url, 'application/json'); + + return transformProps(raw, info.name); + } catch (error) { + log.warn(`Failed to fetch props for ${info.name}: ${error}`); + + return undefined; + } +}; + +const fetchComponentCss = async (baseUrl: string, version: string, info: ResolvedComponentInfo): Promise => { + if (!info.hasCss) { + return undefined; + } + + const url = `${buildComponentBaseUrl(baseUrl, version, info)}/css`; + + try { + const raw = await fetchApiEndpoint.memo(url, 'application/json'); + + return transformCss(raw, info.name); + } catch (error) { + log.warn(`Failed to fetch CSS for ${info.name}: ${error}`); + + return undefined; + } +}; + +const fetchComponentExamples = async ( + baseUrl: string, + version: string, + info: ResolvedComponentInfo, + maxExamples = 3 +): Promise => { + if (info.exampleCount === 0) { + return undefined; + } + + const tab = info.tabs.includes('react') ? 'react' : info.tabs[0]; + + if (!tab) { + return undefined; + } + + const examplesListUrl = `${buildComponentBaseUrl(baseUrl, version, info)}/${tab}/examples`; + + try { + const raw = await fetchApiEndpoint.memo(examplesListUrl, 'application/json'); + const exampleNames: string[] = JSON.parse(raw); + const limited = exampleNames.slice(0, maxExamples); + + const examples: string[] = []; + + for (const exampleName of limited) { + try { + const code = await fetchApiEndpoint.memo(`${examplesListUrl}/${exampleName}`); + + examples.push(`### Example: ${exampleName}\n\n\`\`\`tsx\n${code}\n\`\`\``); + } catch (error) { + log.warn(`Failed to fetch example ${exampleName} for ${info.name}: ${error}`); + } + } + + return examples.length > 0 ? examples : undefined; + } catch (error) { + log.warn(`Failed to fetch examples list for ${info.name}: ${error}`); + + return undefined; + } +}; + +/** + * Fetch component data from the doc-core API, combining multiple data types in parallel. + * + * @param name + * @param include + * @param options + * @note The `include` parameter controls which API endpoints are called. This allows + * consumers to request only what they need (e.g., `['docs', 'props']`) to minimize + * token usage and latency. + */ +const fetchComponentData = async ( + name: string, + include: DocIncludeType[] = ['docs', 'props'], + options = getOptions() +): Promise => { + const info = await getComponentInfo.memo(name, options); + + if (!info) { + return undefined; + } + + const baseUrl = options.apiBaseUrl; + const version = await getApiVersion.memo(options); + + const result: FetchedComponentData = { name, info }; + + // When using the bundled fallback index, the API is unreachable — skip fetches + // that would all fail and return only the component metadata. + if (version === 'fallback') { + return result; + } + + const fetches: Promise[] = []; + + if (include.includes('docs')) { + fetches.push( + fetchComponentDocs(baseUrl, version, info).then(docs => { + if (docs) { + result.docs = docs; + } + }) + ); + } + + if (include.includes('props')) { + fetches.push( + fetchComponentProps(baseUrl, version, info).then(props => { + if (props) { + result.props = props; + } + }) + ); + } + + if (include.includes('css')) { + fetches.push( + fetchComponentCss(baseUrl, version, info).then(css => { + if (css) { + result.css = css; + } + }) + ); + } + + if (include.includes('examples')) { + fetches.push( + fetchComponentExamples(baseUrl, version, info).then(examples => { + if (examples) { + result.examples = examples; + } + }) + ); + } + + await Promise.all(fetches); + + return result; +}; + +fetchComponentData.memo = memo(fetchComponentData, DEFAULT_OPTIONS.toolMemoOptions.usePatternFlyDocs); + +export { + fetchApiEndpoint, + fetchComponentData, + fetchComponentDocs, + fetchComponentProps, + fetchComponentCss, + fetchComponentExamples, + type FetchedComponentData +}; diff --git a/src/api.transforms.ts b/src/api.transforms.ts new file mode 100644 index 00000000..285610bc --- /dev/null +++ b/src/api.transforms.ts @@ -0,0 +1,158 @@ +interface PropEntry { + name: string; + type?: string; + defaultValue?: string | number | boolean; + required?: boolean; + description?: string; +} + +interface ComponentPropsData { + description?: string; + props?: PropEntry[]; +} + +interface CssToken { + name?: string; + var?: string; + value?: string; +} + +/** + * Transform raw markdown from the doc-core API into a token-efficient format. + * + * @param raw + * @param componentName + * @note The doc-core `/text` endpoint returns raw MDX which includes `` tags, + * import statements, and HTML comments. These are noise for LLM consumption and are stripped. + * The `` tags are replaced with `[Example: name]` references so the LLM knows + * examples exist without the heavy JSX. + */ +const transformDocs = (raw: string, componentName: string): string => { + let result = raw; + + result = result.replace(/^import\s+.*$/gm, ''); + + result = result.replace( + /]*?(?:src=\{?["']?([^"'}\s>]+)["']?\}?)?[^>]*?\/?>/gi, + (_match, src) => { + const exampleName = src + ? src.replace(/.*\//, '').replace(/\.[^.]+$/, '') + : componentName; + + return `[Example: ${exampleName}]`; + } + ); + + result = result.replace(//g, ''); + result = result.replace(/\n{3,}/g, '\n\n'); + + return result.trim(); +}; + +/** + * Transform raw props JSON into a compact markdown table. + * + * @param raw + * @param componentName + * @note The doc-core `/props` endpoint returns a keyed object where each key is a component + * name (e.g., "AlertProps", "Alert") and the value contains a `props` array. A single page + * can return multiple components (e.g., Alert page returns Alert, AlertGroup, AlertIcon). + * + * @example Input shape: + * ```json + * { + * "Alert": { + * "description": "...", + * "props": [{ "name": "variant", "type": "'success' | 'danger'", "required": false }] + * } + * } + * ``` + */ +const transformProps = (raw: string, componentName: string): string => { + const data = JSON.parse(raw); + const sections: string[] = []; + + const entries = typeof data === 'object' && data !== null + ? Object.entries(data) as [string, ComponentPropsData][] + : []; + + for (const [name, component] of entries) { + const props: PropEntry[] = component?.props || []; + + if (props.length === 0) { + continue; + } + + const lines: string[] = []; + + lines.push(`## ${name} Props`); + + if (component?.description) { + lines.push(''); + lines.push(component.description); + } + + lines.push(''); + lines.push('| Prop | Type | Default | Required | Description |'); + lines.push('|------|------|---------|----------|-------------|'); + + for (const prop of props) { + const type = escapeTableCell(prop.type || '-'); + const defaultVal = prop.defaultValue != null ? escapeTableCell(String(prop.defaultValue)) : '-'; + const required = prop.required ? '**yes**' : 'no'; + const description = escapeTableCell(prop.description || '-'); + + lines.push(`| ${prop.name} | ${type} | ${defaultVal} | ${required} | ${description} |`); + } + + sections.push(lines.join('\n')); + } + + if (sections.length === 0) { + return `## ${componentName} Props\n\nNo props data available.`; + } + + return sections.join('\n\n'); +}; + +/** + * Transform raw CSS variables JSON into a compact markdown table. + * + * @param raw + * @param componentName + * @note The doc-core `/css` endpoint returns an array of token objects. The field name + * varies between `name` and `var` depending on the token type, so both are checked. + */ +const transformCss = (raw: string, componentName: string): string => { + const tokens: CssToken[] = JSON.parse(raw); + + if (!Array.isArray(tokens) || tokens.length === 0) { + return `## ${componentName} CSS Variables\n\nNo CSS variables available.`; + } + + const lines: string[] = []; + + lines.push(`## ${componentName} CSS Variables`); + lines.push(''); + lines.push('| Variable | Value |'); + lines.push('|----------|-------|'); + + for (const token of tokens) { + const name = token.name || token.var || '-'; + const value = escapeTableCell(token.value || '-'); + + lines.push(`| ${name} | ${value} |`); + } + + return lines.join('\n'); +}; + +const escapeTableCell = (value: string): string => + value.replace(/\|/g, '\\|').replace(/\n/g, ' '); + +export { + transformDocs, + transformProps, + transformCss, + escapeTableCell +}; diff --git a/src/api.types.ts b/src/api.types.ts new file mode 100644 index 00000000..43723fbd --- /dev/null +++ b/src/api.types.ts @@ -0,0 +1,30 @@ +/** + * Matches the output shape of patternfly-doc-core's /api/component-index.json endpoint. + */ +interface ComponentEntry { + section: string; + page: string; + tabs: string[]; + hasProps: boolean; + hasCss: boolean; + exampleCount: number; +} + +/** + * Matches the output shape of patternfly-doc-core's /api/component-index.json endpoint. + */ +interface ComponentIndex { + version: string; + components: Record; +} + +type ResolvedComponentInfo = ComponentEntry & { name: string }; + +type DocIncludeType = 'docs' | 'props' | 'examples' | 'css'; + +export { + type ComponentEntry, + type ComponentIndex, + type ResolvedComponentInfo, + type DocIncludeType +}; diff --git a/src/options.defaults.ts b/src/options.defaults.ts index cc44bb1a..3e0b2f84 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -9,6 +9,7 @@ import { type ToolModule } from './server.toolsUser'; * @interface DefaultOptions * * @template TLogOptions The logging options type, defaulting to LoggingOptions. + * @property apiBaseUrl - Base URL for the PatternFly doc-core API. * @property contextPath - Current working directory. * @property contextUrl - Current working directory URL. * @property docsPath - Path to the documentation directory. @@ -50,6 +51,7 @@ import { type ToolModule } from './server.toolsUser'; * @property xhrFetch - XHR and Fetch options. */ interface DefaultOptions { + apiBaseUrl: string; contextPath: string; contextUrl: string; docsPath: string; @@ -374,6 +376,12 @@ const PATTERNFLY_OPTIONS: PatternFlyOptions = { } }; +/** + * Base URL for the PatternFly doc-core API. + * Used to fetch the MCP index and component data at runtime. + */ +const API_BASE_URL = 'https://staging.patternfly.org'; + /** * URL regex pattern for detecting external URLs */ @@ -472,6 +480,7 @@ const getNodeMajorVersion = (nodeVersion = process.versions.node) => { * @type {DefaultOptions} Default options object. */ const DEFAULT_OPTIONS: DefaultOptions = { + apiBaseUrl: process.env.API_BASE_URL || API_BASE_URL, contextPath: (process.env.NODE_ENV === 'local' && '/') || resolve(process.cwd()), contextUrl: pathToFileURL((process.env.NODE_ENV === 'local' && '/') || resolve(process.cwd())).href, docsPath: (process.env.NODE_ENV === 'local' && '/documentation') || join(resolve(process.cwd()), 'documentation'), @@ -511,6 +520,7 @@ const DEFAULT_OPTIONS: DefaultOptions = { }; export { + API_BASE_URL, LOG_BASENAME, DEFAULT_OPTIONS, MODE_LEVELS, diff --git a/src/resource.patternFlyDocsIndex.ts b/src/resource.patternFlyDocsIndex.ts index b212cf92..302c0d7d 100644 --- a/src/resource.patternFlyDocsIndex.ts +++ b/src/resource.patternFlyDocsIndex.ts @@ -1,8 +1,7 @@ -import { COMPONENT_DOCS } from './docs.component'; -import { LAYOUT_DOCS } from './docs.layout'; -import { CHART_DOCS } from './docs.chart'; +import { getComponentList, getComponentInfo } from './api.client'; import { getLocalDocs } from './docs.local'; import { type McpResource } from './server'; +import { getOptions } from './options.context'; import { stringJoin } from './server.helpers'; /** @@ -27,27 +26,49 @@ const CONFIG = { /** * Resource creator for the documentation index. * + * @param options - Global options * @returns {McpResource} The resource definition tuple */ -const patternFlyDocsIndexResource = (): McpResource => [ +const patternFlyDocsIndexResource = (options = getOptions()): McpResource => [ NAME, URI_TEMPLATE, CONFIG, async () => { + const componentNames = await getComponentList.memo(options); + + // Group components by section + const sections = new Map(); + + for (const name of componentNames) { + const info = await getComponentInfo.memo(name, options); + const section = info?.section || 'other'; + + if (!sections.has(section)) { + sections.set(section, []); + } + + sections.get(section)!.push(name); + } + + const sectionBlocks: string[] = []; + + for (const [section, names] of sections) { + sectionBlocks.push( + '', + `## ${section.charAt(0).toUpperCase() + section.slice(1)}`, + ...names.map(name => `- ${name}`) + ); + } + + const localDocs = getLocalDocs(); + const localBlock = localDocs.length > 0 + ? ['', '## Local Documentation', ...localDocs] + : []; + const allDocs = stringJoin.newline( '# PatternFly Documentation Index', - '', - '## Components', - ...COMPONENT_DOCS, - '', - '## Layouts', - ...LAYOUT_DOCS, - '', - '## Charts', - ...CHART_DOCS, - '', - '## Local Documentation', - ...getLocalDocs() + ...sectionBlocks, + ...localBlock ); return { diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index c15d05fe..db45abc7 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -1,10 +1,9 @@ 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 { fetchComponentData } from './api.fetcher'; import { searchComponents } from './tool.searchPatternFlyDocs'; import { getOptions } from './options.context'; -import { memo } from './server.caching'; import { stringJoin } from './server.helpers'; /** @@ -32,88 +31,56 @@ const CONFIG = { * @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}` - ); - } - - if (name.length > options.maxSearchLength) { - throw new McpError( - ErrorCode.InvalidParams, - `Resource name exceeds maximum length of ${options.maxSearchLength} characters.` - ); - } - - const docResults = []; - const docs = []; - const { exactMatches, searchResults } = searchComponents.memo(name); +const patternFlyDocsTemplateResource = (options = getOptions()): McpResource => [ + 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}` + ); + } - if (exactMatches.length === 0 || exactMatches.every(match => match.urls.length === 0)) { - const suggestions = searchResults.map(searchResult => searchResult.item).slice(0, 3); - const suggestionMessage = suggestions.length - ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` - : 'No similar components found.'; + if (name.length > options.maxSearchLength) { + throw new McpError( + ErrorCode.InvalidParams, + `Resource name exceeds maximum length of ${options.maxSearchLength} characters.` + ); + } - throw new McpError( - ErrorCode.InvalidParams, - `No documentation found for component "${name.trim()}". ${suggestionMessage}` - ); - } + const data = await fetchComponentData.memo(name, ['docs'], options); - try { - const exactMatchesUrls = exactMatches.flatMap(match => match.urls); + if (!data || !data.docs) { + const { searchResults } = await searchComponents.memo(name, {}, options); + const suggestions = searchResults.map(result => result.item).slice(0, 3); + const suggestionMessage = suggestions.length + ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` + : 'No similar components found.'; - if (exactMatchesUrls.length > 0) { - const processedDocs = await memoProcess(exactMatchesUrls); + throw new McpError( + ErrorCode.InvalidParams, + `No documentation found for component "${name.trim()}". ${suggestionMessage}` + ); + } - docs.push(...processedDocs); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: stringJoin.newline( + `# Documentation for ${data.name}`, + '', + data.docs + ) } - } catch (error) { - throw new McpError( - ErrorCode.InternalError, - `Failed to fetch documentation: ${error}` - ); - } - - // Redundancy check, technically this should never happen, future proofing - if (docs.length === 0) { - throw new McpError( - ErrorCode.InvalidParams, - `Component "${name.trim()}" was found, but no documentation URLs are available for it.` - ); - } - - for (const doc of docs) { - docResults.push(stringJoin.newline( - `# Documentation from ${doc.resolvedPath || doc.path}`, - '', - doc.content - )); - } - - return { - contents: [ - { - uri: uri.href, - mimeType: 'text/markdown', - text: docResults.join(options.separator) - } - ] - }; - } - ]; -}; + ] + }; + } +]; export { patternFlyDocsTemplateResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlySchemasIndex.ts b/src/resource.patternFlySchemasIndex.ts index bbb8ec0d..1993606d 100644 --- a/src/resource.patternFlySchemasIndex.ts +++ b/src/resource.patternFlySchemasIndex.ts @@ -1,5 +1,6 @@ -import { componentNames } from '@patternfly/patternfly-component-schemas/json'; +import { getComponentList, getComponentInfo } from './api.client'; import { type McpResource } from './server'; +import { getOptions } from './options.context'; import { stringJoin } from './server.helpers'; /** @@ -17,31 +18,44 @@ const URI_TEMPLATE = 'patternfly://schemas/index'; */ const CONFIG = { title: 'PatternFly Component Schemas Index', - description: 'A list of all PatternFly component names available for JSON Schema retrieval', + description: 'A list of all PatternFly component names available for prop schema retrieval', mimeType: 'text/markdown' }; /** * Resource creator for the component schemas index. * + * @param options - Global options * @returns {McpResource} The resource definition tuple */ -const patternFlySchemasIndexResource = (): McpResource => [ +const patternFlySchemasIndexResource = (options = getOptions()): McpResource => [ NAME, URI_TEMPLATE, CONFIG, - async () => ({ - contents: [{ - uri: 'patternfly://schemas/index', - mimeType: 'text/markdown', - text: stringJoin.newline( - '# PatternFly Component Names Index', - '', - '', - ...componentNames - ) - }] - }) + async () => { + const componentNames = await getComponentList.memo(options); + const withProps: string[] = []; + + for (const name of componentNames) { + const info = await getComponentInfo.memo(name, options); + + if (info?.hasProps) { + withProps.push(name); + } + } + + return { + contents: [{ + uri: 'patternfly://schemas/index', + mimeType: 'text/markdown', + text: stringJoin.newline( + '# PatternFly Component Schemas Index', + '', + ...withProps.map(name => `- ${name}`) + ) + }] + }; + } ]; export { patternFlySchemasIndexResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/resource.patternFlySchemasTemplate.ts b/src/resource.patternFlySchemasTemplate.ts index ac051bf6..e4e52fff 100644 --- a/src/resource.patternFlySchemasTemplate.ts +++ b/src/resource.patternFlySchemasTemplate.ts @@ -1,15 +1,10 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { componentNames as pfComponentNames } from '@patternfly/patternfly-component-schemas/json'; import { type McpResource } from './server'; import { getOptions } from './options.context'; -import { getComponentSchema } from './tool.patternFlyDocs'; +import { fetchComponentData } from './api.fetcher'; import { searchComponents } from './tool.searchPatternFlyDocs'; - -/** - * Derive the component schema type from @patternfly/patternfly-component-schemas - */ -type ComponentSchema = Awaited>; +import { stringJoin } from './server.helpers'; /** * Name of the resource template. @@ -26,8 +21,8 @@ const URI_TEMPLATE = new ResourceTemplate('patternfly://schemas/{name}', { list: */ const CONFIG = { title: 'PatternFly Component Schema', - description: 'Retrieve the JSON Schema for a specific PatternFly component by name', - mimeType: 'application/json' + description: 'Retrieve the prop schema for a specific PatternFly component by name', + mimeType: 'text/markdown' }; /** @@ -57,26 +52,15 @@ const patternFlySchemasTemplateResource = (options = getOptions()): McpResource ); } - const { exactMatches, searchResults } = searchComponents.memo(name, { names: pfComponentNames }); - let result: ComponentSchema | undefined = undefined; - - if (exactMatches.length > 0) { - for (const match of exactMatches) { - const schema = await getComponentSchema.memo(match.item); - - if (schema) { - result = schema; - break; - } - } - } + const data = await fetchComponentData.memo(name, ['props'], options); - if (result === undefined) { - const suggestions = searchResults.map(searchResult => searchResult.item).slice(0, 3); + if (!data || !data.props) { + const { searchResults } = await searchComponents.memo(name, {}, options); + const suggestions = searchResults.map(result => result.item).slice(0, 3); const suggestionMessage = suggestions.length ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` : 'No similar components found.'; - const foundNotFound = exactMatches.length ? 'found but JSON schema not available.' : 'not found.'; + const foundNotFound = data ? 'found but prop schema not available.' : 'not found.'; throw new McpError( ErrorCode.InvalidParams, @@ -88,8 +72,12 @@ const patternFlySchemasTemplateResource = (options = getOptions()): McpResource contents: [ { uri: uri.href, - mimeType: 'application/json', - text: JSON.stringify(result, null, 2) + mimeType: 'text/markdown', + text: stringJoin.newline( + `# Props for ${data.name}`, + '', + data.props + ) } ] }; diff --git a/src/tool.componentSchemas.ts b/src/tool.componentSchemas.ts index f0075319..ca3cce1f 100644 --- a/src/tool.componentSchemas.ts +++ b/src/tool.componentSchemas.ts @@ -1,32 +1,21 @@ import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { type McpTool } from './server'; import { getOptions } from './options.context'; -import { memo } from './server.caching'; +import { getComponentList } from './api.client'; +import { fetchComponentData } from './api.fetcher'; import { fuzzySearch } from './server.search'; -import { componentNames } from './tool.searchPatternFlyDocs'; - -/** - * Derive the component schema type from @patternfly/patternfly-component-schemas - */ -type ComponentSchema = Awaited>; /** * componentSchemas tool function * - * Creates an MCP tool that retrieves JSON Schema for PatternFly React components. + * Creates an MCP tool that retrieves prop schemas 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 MCP tool tuple [name, schema, callback] */ const componentSchemasTool = (options = getOptions()): McpTool => { - const memoGetComponentSchema = memo( - async (componentName: string): Promise => getComponentSchema(componentName), - options?.toolMemoOptions?.usePatternFlyDocs - ); - const callback = async (args: any = {}) => { const { componentName } = args; @@ -44,7 +33,8 @@ const componentSchemasTool = (options = getOptions()): McpTool => { ); } - // Use fuzzySearch with `isFuzzyMatch` to handle exact and intentional suggestions in one pass + const componentNames = await getComponentList.memo(options); + const { results } = fuzzySearch(componentName, componentNames, { maxDistance: 3, maxResults: 5, @@ -55,14 +45,12 @@ const componentSchemasTool = (options = getOptions()): McpTool => { const exact = results.find(result => result.matchType === 'exact'); if (exact) { - let componentSchema: ComponentSchema; + const data = await fetchComponentData.memo(exact.item, ['props'], options); - try { - componentSchema = await memoGetComponentSchema(exact.item); - } catch (error) { + if (!data?.props) { throw new McpError( ErrorCode.InternalError, - `Failed to fetch component schema: ${error}` + `Component "${exact.item}" found but prop schema is not available.` ); } @@ -70,7 +58,7 @@ const componentSchemasTool = (options = getOptions()): McpTool => { content: [ { type: 'text', - text: JSON.stringify(componentSchema, null, 2) + text: data.props } ] }; @@ -92,7 +80,7 @@ const componentSchemasTool = (options = getOptions()): McpTool => { { description: `[Deprecated: Use "usePatternFlyDocs" to retrieve component schemas from PatternFly documentation URLs.] - Get JSON Schema for a PatternFly React component. + Get prop schema for a PatternFly React component. Returns prop definitions, types, and validation rules. Use this for structured component metadata, not documentation.`, inputSchema: { diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index 54c76adf..5a9d8d77 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -1,33 +1,12 @@ import { z } from 'zod'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import { getComponentSchema as pfGetComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { type McpTool } from './server'; import { getOptions } from './options.context'; -import { processDocsFunction } from './server.getResources'; -import { memo } from './server.caching'; +import { fetchComponentData } from './api.fetcher'; +import { searchComponents } from './tool.searchPatternFlyDocs'; import { stringJoin } from './server.helpers'; -import { setComponentToDocsMap, searchComponents } from './tool.searchPatternFlyDocs'; -import { DEFAULT_OPTIONS } from './options.defaults'; import { log } from './logger'; -/** - * Get the component schema from @patternfly/patternfly-component-schemas. - * - * @param componentName - */ -const getComponentSchema = async (componentName: string) => { - try { - return await pfGetComponentSchema(componentName); - } catch {} - - return undefined; -}; - -/** - * Memoized version of getComponentSchema. - */ -getComponentSchema.memo = memo(getComponentSchema, DEFAULT_OPTIONS.toolMemoOptions.usePatternFlyDocs); - /** * usePatternFlyDocs tool function * @@ -35,150 +14,118 @@ getComponentSchema.memo = memo(getComponentSchema, DEFAULT_OPTIONS.toolMemoOptio * @returns MCP tool tuple [name, schema, callback] */ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { - const memoProcess = memo(processDocsFunction, options?.toolMemoOptions?.usePatternFlyDocs); - const { getKey: getComponentToDocsKey } = setComponentToDocsMap.memo(); - const callback = async (args: any = {}) => { - const { urlList, name } = args; - const isUrlList = urlList && Array.isArray(urlList) && urlList.length > 0 && urlList.every(url => typeof url === 'string' && url.trim().length > 0); + const { name } = args; const isName = typeof name === 'string' && name.trim().length > 0; - const hasUri = (isName && new RegExp('patternfly://', 'i').test(name)) || (isUrlList && urlList.some(url => new RegExp('patternfly://', 'i').test(url))); + const hasUri = isName && new RegExp('patternfly://', 'i').test(name); if (hasUri) { throw new McpError( ErrorCode.InvalidParams, stringJoin.basic( 'Direct "patternfly://" URIs are not supported as tool inputs, and are intended to be used directly.', - 'Use a component "name" or provide a "urlList" of raw documentation URLs.' + 'Use a component "name" to fetch documentation.' ) ); } - if ((isUrlList && isName) || (!isUrlList && !isName)) { + if (!isName) { throw new McpError( ErrorCode.InvalidParams, - `Provide either a string "name" OR an array of strings "urlList".` + `Provide a string "name" of a PatternFly component (e.g., "Button", "Alert").` ); } - if (isName && name.length > options.maxSearchLength) { + if (name.length > options.maxSearchLength) { throw new McpError( ErrorCode.InvalidParams, `String "name" exceeds maximum length of ${options.maxSearchLength} characters.` ); } - const updatedUrlList = isUrlList ? urlList.slice(0, options.recommendedMaxDocsToLoad) : []; + const data = await fetchComponentData.memo(name, ['docs', 'props'], options); + + if (!data) { + // Component not found — try fuzzy search for suggestions + const { searchResults } = await searchComponents.memo(name, {}, options); + const suggestions = searchResults.map(result => result.item).slice(0, 3); + const suggestionMessage = suggestions.length + ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` + : 'No similar components found.'; - if (isUrlList && urlList.length > options.recommendedMaxDocsToLoad) { - log.warn( - `usePatternFlyDocs: urlList truncated from ${urlList.length} to ${options.recommendedMaxDocsToLoad} items.` + throw new McpError( + ErrorCode.InvalidParams, + `Component "${name.trim()}" not found. ${suggestionMessage}` ); } - if (name) { - const { exactMatches, searchResults } = searchComponents.memo(name); - - if (exactMatches.length === 0 || exactMatches.every(match => match.urls.length === 0)) { - const suggestions = searchResults.map(result => result.item).slice(0, 3); - const suggestionMessage = suggestions.length - ? `Did you mean ${suggestions.map(suggestion => `"${suggestion}"`).join(', ')}?` - : 'No similar components found.'; + const sections: string[] = []; - throw new McpError( - ErrorCode.InvalidParams, - `Component "${name.trim()}" not found. ${suggestionMessage}` - ); - } - - updatedUrlList.push(...exactMatches.flatMap(match => match.urls)); + if (data.docs) { + sections.push(stringJoin.newline( + `# Documentation for ${data.name}`, + '', + data.docs + )); } - const docs = []; - const schemasSeen = new Set(); - const schemaResults = []; - const docResults = []; + if (data.props) { + sections.push(stringJoin.newline( + `# Props for ${data.name}`, + '', + data.props + )); + } - try { - const processedDocs = await memoProcess(updatedUrlList); + if (data.examples && data.examples.length > 0) { + sections.push(stringJoin.newline( + `# Examples for ${data.name}`, + '', + ...data.examples + )); + } - docs.push(...processedDocs); - } catch (error) { - throw new McpError( - ErrorCode.InternalError, - `Failed to fetch documentation: ${error}` - ); + if (data.css) { + sections.push(stringJoin.newline( + `# CSS Variables for ${data.name}`, + '', + data.css + )); } - if (docs.length === 0) { - const urlListBlock = updatedUrlList.map((url: string, index: number) => ` ${index + 1}. ${url}`).join('\n'); + if (sections.length === 0) { + log.warn(`usePatternFlyDocs: component "${name}" found but no content available`); return { content: [{ type: 'text', - text: stringJoin.newline( - `No PatternFly documentation found for:`, - urlListBlock, - '', - '---', - '', - '**Important**:', - ' - To browse all available components use "searchPatternFlyDocs" with a search all ("*").' - ) + text: `Component "${name}" was found but no documentation content is available.` }] }; } - for (const doc of docs) { - const componentName = getComponentToDocsKey(doc.path); - - docResults.push(stringJoin.newline( - `# Documentation${(componentName && ` for ${componentName}`) || ''} from ${doc.path || 'unknown'}`, - '', - doc.content - )); - - if (componentName && !schemasSeen.has(componentName)) { - schemasSeen.add(componentName); - const componentSchema = await getComponentSchema.memo(componentName); - - if (componentSchema) { - schemaResults.push(stringJoin.newline( - `# Component Schema for ${componentName}`, - `This machine-readable JSON schema defines the component's props, types, and validation rules.`, - '```json', - JSON.stringify(componentSchema, null, 2), - '```' - )); - } - } - } - return { - content: [ - { - type: 'text', - text: [...docResults, ...schemaResults].join(options.separator) - } - ] + content: [{ + type: 'text', + text: sections.join(options.separator) + }] }; }; return [ 'usePatternFlyDocs', { - description: `Get markdown documentation and component JSON schemas for PatternFly components. + description: `Get markdown documentation and component props for PatternFly components. **Usage**: - 1. Input a component name (e.g., "Button") OR a list of up to ${options.recommendedMaxDocsToLoad} documentation URLs at a time (typically from searchPatternFlyDocs results). + 1. Input a component name (e.g., "Button", "Alert") to fetch its documentation and props. **Returns**: - Markdown documentation - - Component JSON schemas, if available + - Component props as a formatted table `, inputSchema: { - urlList: z.array(z.string()).max(options.recommendedMaxDocsToLoad).optional().describe(`The list of URLs to fetch the documentation from (max ${options.recommendedMaxDocsToLoad} at a time`), - name: z.string().max(options.maxSearchLength).optional().describe('The name of a PatternFly component to fetch documentation for (e.g., "Button", "Table")') + name: z.string().max(options.maxSearchLength).describe('The name of a PatternFly component to fetch documentation for (e.g., "Button", "Table")') } }, callback @@ -190,4 +137,4 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { */ usePatternFlyDocsTool.toolName = 'usePatternFlyDocs'; -export { usePatternFlyDocsTool, getComponentSchema }; +export { usePatternFlyDocsTool }; diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts index 37b408c5..9c14c512 100644 --- a/src/tool.searchPatternFlyDocs.ts +++ b/src/tool.searchPatternFlyDocs.ts @@ -1,11 +1,7 @@ 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 { getComponentList, getComponentInfo } from './api.client'; import { fuzzySearch, type FuzzySearchResult } from './server.search'; import { getOptions } from './options.context'; import { memo } from './server.caching'; @@ -13,170 +9,58 @@ import { stringJoin } from './server.helpers'; 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 an internal 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. This is a short-term solution - * until we can move the internal links to a new format like: - * ``` - * { - * name: 'Charts', - * description: 'Colors for Charts', - * type: 'example', - * scope: '@patternfly', - * url: `${PF_EXTERNAL_EXAMPLES_CHARTS}/ChartTheme/examples/ChartTheme.md` - * } - * ``` - * - * @example - * extractComponentName('[@patternfly/ComponentName - Type](URL)'); - * - * @param docUrl - Documentation URL string - * @returns ComponentName or `null` if not found - */ -const extractComponentName = (docUrl: string): string | null => { - // Stop at space or closing bracket, allowing dashes in the name - const match = docUrl.match(/\[@patternfly\/([^\s\]]+)/); - const name = match && match[1] ? match[1].trim() : null; - - // Filter out known non-component patterns - if (name?.startsWith('react-')) { - return null; - } - - return name; -}; - -/** - * Extract a URL from an internal Markdown link. - * - * @note This is a short-term solution until we can move the internal links to a new format. - * - * @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 internal documentation URLs. - * - * @returns Map of component name -> array of URLs (Design Guidelines + Accessibility) - */ -const setComponentToDocsMap = () => { - const map = new Map(); - const allDocs = [...COMPONENT_DOCS, ...LAYOUT_DOCS, ...CHART_DOCS, ...getLocalDocs()]; - const getKey = (value?: string | undefined) => { - if (!value) { - return undefined; - } - - for (const [key, urls] of map) { - if (urls.includes(value)) { - return key; - } else { - const { results } = fuzzySearch(value, urls, { - deduplicateByNormalized: true - }); - - if (results.length) { - return key; - } - } - } - - return undefined; - }; - - allDocs.forEach(docUrl => { - const componentName = extractComponentName(docUrl); - - if (componentName) { - const url = extractUrl(docUrl); - const existing = map.get(componentName) || []; - - map.set(componentName, [...existing, url]); - } - }); - - return { - map, - getKey - }; -}; - -/** - * Memoized version of componentToDocsMap. - */ -setComponentToDocsMap.memo = memo(setComponentToDocsMap); - -/** - * Search for PatternFly component documentation URLs using fuzzy search. + * Search for PatternFly components using fuzzy search. * * @param searchQuery - Search query string * @param settings - Optional settings object - * @param settings.names - List of names to search. Defaults to all component names. * @param settings.allowWildCardAll - Allow a search query to match all components. Defaults to false. - * @returns Object containing search results and matched URLs - * - `isSearchWildCardAll`: Whether the search query matched all components - * - `firstExactMatch`: First exact match within fuzzy search results - * - `exactMatches`: All exact matches within fuzzy search results - * - `searchResults`: Fuzzy search results + * @param options - Global options + * @returns Object containing search results and component metadata + * + * @note Component list is fetched from the doc-core API and cached in memory. + * The search is async because it awaits the API-sourced component index. */ -const searchComponents = (searchQuery: string, { names = componentNames, allowWildCardAll = false } = {}) => { +const searchComponents = async (searchQuery: string, { allowWildCardAll = false } = {}, options = getOptions()) => { + const componentNames = await getComponentList.memo(options); const isWildCardAll = searchQuery.trim() === '*' || searchQuery.trim().toLowerCase() === 'all' || searchQuery.trim() === ''; const isSearchWildCardAll = allowWildCardAll && isWildCardAll; - const { map: componentToDocsMap } = setComponentToDocsMap.memo(); let searchResults: FuzzySearchResult[] = []; if (isSearchWildCardAll) { searchResults = componentNames.map(name => ({ matchType: 'all', distance: 0, item: name } as FuzzySearchResult)); } else { - const search = fuzzySearch(searchQuery, names, { + const { results } = fuzzySearch(searchQuery, componentNames, { maxDistance: 3, maxResults: 10, isFuzzyMatch: true, deduplicateByNormalized: true }); - searchResults = search.results; + searchResults = results; } - const extendResults = (results: FuzzySearchResult[] = []) => results.map(result => { - const isSchemasAvailable = pfComponentNames.includes(result.item); - const urls = componentToDocsMap.get(result.item) || []; - const matchedUrls = new Set(); + const extendResults = async (results: FuzzySearchResult[] = []) => { + const extended = []; - urls.forEach(url => { - matchedUrls.add(url); - }); + for (const result of results) { + const info = await getComponentInfo.memo(result.item, options); - return { - ...result, - doc: `patternfly://docs/${result.item}`, - isSchemasAvailable, - schema: isSchemasAvailable ? `patternfly://schemas/${result.item}` : undefined, - urls: Array.from(matchedUrls) - }; - }); + extended.push({ + ...result, + section: info?.section, + hasProps: info?.hasProps ?? false, + hasCss: info?.hasCss ?? false, + exampleCount: info?.exampleCount ?? 0, + tabs: info?.tabs ?? [] + }); + } + + return extended; + }; const exactMatches = searchResults.filter(result => result.matchType === 'exact'); - const extendedExactMatches = extendResults(exactMatches); - const extendedSearchResults = extendResults(searchResults); + const extendedExactMatches = await extendResults(exactMatches); + const extendedSearchResults = await extendResults(searchResults); return { isSearchWildCardAll, @@ -194,8 +78,8 @@ searchComponents.memo = memo(searchComponents, DEFAULT_OPTIONS.toolMemoOptions.s /** * 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. + * Searches for PatternFly components using fuzzy search. + * Returns component metadata (does not fetch content). Use usePatternFlyDocs to fetch the actual content. * * @param options - Optional configuration options (defaults to OPTIONS) * @returns MCP tool tuple [name, schema, callback] @@ -218,7 +102,7 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { ); } - const { isSearchWildCardAll, searchResults } = searchComponents.memo(searchQuery, { allowWildCardAll: true }); + const { isSearchWildCardAll, searchResults } = await searchComponents.memo(searchQuery, { allowWildCardAll: true }, options); if (!isSearchWildCardAll && searchResults.length === 0) { return { @@ -237,17 +121,19 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { } const results = searchResults.map(result => { - const urlList = result.urls.map((url: string, index: number) => ` ${index + 1}. ${url}`).join('\n'); + const available = [ + result.hasProps && 'props', + result.hasCss && 'css', + result.exampleCount > 0 && `${result.exampleCount} examples` + ].filter(Boolean).join(', '); return stringJoin.newline( '', `## ${result.item}`, `**Match Type**: ${result.matchType}`, - `### "usePatternFlyDocs" tool documentation URLs`, - urlList.length ? urlList : ' - No URLs found', - `### Resources metadata`, - ` - **Component name**: ${result.item}`, - ` - **JSON Schemas**: ${result.isSchemasAvailable ? 'Available' : 'Not available'}` + `**Section**: ${result.section || 'unknown'}`, + `**Available Data**: ${available || 'docs only'}`, + `**Tabs**: ${result.tabs.join(', ') || 'none'}` ); }); @@ -261,7 +147,7 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { '---', '', '**Important**:', - ' - Use the "usePatternFlyDocs" tool with the above URLs to fetch documentation content.', + ' - Use the "usePatternFlyDocs" tool with a component name to fetch documentation content.', ' - Use a search all ("*") to find all available components.' ) }] @@ -271,15 +157,14 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { return [ 'searchPatternFlyDocs', { - description: `Search PatternFly components and get component names with documentation URLs. Supports case-insensitive partial and all ("*") matches. + description: `Search PatternFly components and get component metadata. Supports case-insensitive partial and all ("*") matches. **Usage**: - 1. Input a "searchQuery" to find PatternFly documentation URLs and component names. - 2. Use the returned component names OR URLs with the "usePatternFlyDocs" tool to get markdown documentation and component JSON schemas. + 1. Input a "searchQuery" to find PatternFly components. + 2. Use the returned component names with the "usePatternFlyDocs" tool to get markdown documentation and props. **Returns**: - - Component names that can be used with "usePatternFlyDocs" - - Documentation URLs that can be used with "usePatternFlyDocs" + - Component names, sections, and available data types (props, css, examples) `, inputSchema: { searchQuery: z.string().max(options.maxSearchLength).describe('Full or partial component name to search for (e.g., "button", "table", "*")') @@ -291,4 +176,4 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { searchPatternFlyDocsTool.toolName = 'searchPatternFlyDocs'; -export { searchPatternFlyDocsTool, searchComponents, setComponentToDocsMap, componentNames }; +export { searchPatternFlyDocsTool, searchComponents };