diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 4b202a0c..2205c1d4 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -74,6 +74,8 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "https://patternfly.org", "https://github.com/patternfly", "https://raw.githubusercontent.com/patternfly", + "https://github.com/project-felt", + "https://raw.githubusercontent.com/project-felt", ], "urlWhitelistProtocols": [ "http", diff --git a/src/__tests__/aiGuidelines.catalog.test.ts b/src/__tests__/aiGuidelines.catalog.test.ts new file mode 100644 index 00000000..74325519 --- /dev/null +++ b/src/__tests__/aiGuidelines.catalog.test.ts @@ -0,0 +1,40 @@ +import { expandGithubDirectoryInCatalog } from '../catalog.expandGithubDirectory'; +import type { PatternFlyMcpDocsCatalogSource } from '../docs.embedded'; +import docsJson from '../docs.json'; +import { installGithubFetchMock, restoreNativeFetch } from './fixtures/mockGithubFetch'; + +const FELT_RAW_PREFIX = 'https://raw.githubusercontent.com/project-felt/ai-guidelines/'; + +const EXPECTED_FILENAMES = [ + 'ai-design-principles.md', + 'animation.md', + 'chatbot-avatars.md', + 'color.md', + 'iconography.md', + 'legal-requirements.md', + 'transparency-notices.md' +]; + +describe('AiGuidelines docs.json catalog', () => { + beforeAll(() => installGithubFetchMock()); + afterAll(() => restoreNativeFetch()); + + it('should expand directory stub into seven project-felt entries', async () => { + const expanded = await expandGithubDirectoryInCatalog(docsJson as PatternFlyMcpDocsCatalogSource); + const entries = expanded.docs.AiGuidelines; + + expect(entries).toBeDefined(); + expect(entries).toHaveLength(EXPECTED_FILENAMES.length); + expect(expanded.meta.totalDocs).toBe(331); + + for (const entry of entries ?? []) { + expect(entry.path.startsWith(FELT_RAW_PREFIX)).toBe(true); + expect(entry.path.endsWith('.md')).toBe(true); + expect(entry.version).toBe('v6'); + } + + const basenames = (entries ?? []).map(docEntry => docEntry.path.split('/').pop()); + + expect(basenames.sort()).toEqual([...EXPECTED_FILENAMES].sort()); + }); +}); diff --git a/src/__tests__/catalog.expandGithubDirectory.test.ts b/src/__tests__/catalog.expandGithubDirectory.test.ts new file mode 100644 index 00000000..3754ecfd --- /dev/null +++ b/src/__tests__/catalog.expandGithubDirectory.test.ts @@ -0,0 +1,37 @@ +import { expandGithubDirectoryInCatalog } from '../catalog.expandGithubDirectory'; +import type { PatternFlyMcpDocsCatalogSource } from '../docs.embedded'; + +describe('expandGithubDirectoryInCatalog', () => { + it('should reject expansion for repos outside the allowlist', async () => { + const catalog: PatternFlyMcpDocsCatalogSource = { + version: '1', + generated: new Date().toISOString(), + meta: { + totalEntries: 1, + totalDocs: 1, + source: 'test' + }, + docs: { + BadExpand: [ + { + displayName: 'Invalid', + description: 'Should fail', + pathSlug: 'invalid', + section: 'guidelines', + category: 'ai', + source: 'github', + version: 'v6', + expandGithubDirectory: { + owner: 'disallowed', + repo: 'vendor', + ref: 'main', + directoryPath: 'docs' + } + } + ] + } + }; + + await expect(expandGithubDirectoryInCatalog(catalog)).rejects.toThrow(/not enabled/); + }); +}); diff --git a/src/__tests__/docs.json.test.ts b/src/__tests__/docs.json.test.ts index 5e31750c..a53be847 100644 --- a/src/__tests__/docs.json.test.ts +++ b/src/__tests__/docs.json.test.ts @@ -1,5 +1,6 @@ import { distance } from 'fastest-levenshtein'; import docsJson from '../docs.json'; +import type { PatternFlyMcpDocsCatalogDocImport } from '../docs.embedded'; describe('docs.json', () => { it('should have a valid top-level generated timestamp (ISO date string)', () => { @@ -23,21 +24,28 @@ describe('docs.json', () => { let totalDocs = 0; Object.entries(docsJson.docs).forEach(([key, entries]) => { - entries.forEach(entry => { + entries.forEach((entry: PatternFlyMcpDocsCatalogDocImport) => { totalDocs += 1; - allLinks.add(entry.path); - const path = entry.path; + const linkKey = 'expandGithubDirectory' in entry + ? `catalog-expand:${entry.expandGithubDirectory.owner}/${entry.expandGithubDirectory.repo}:${entry.expandGithubDirectory.directoryPath}@${entry.expandGithubDirectory.ref}` + : entry.path; - if (!linkMap.has(path)) { - linkMap.set(path, []); + allLinks.add(linkKey); + + if (!linkMap.has(linkKey)) { + linkMap.set(linkKey, []); } - linkMap.get(path)?.push(`${key}: ${entry.displayName} (${entry.category})`); + linkMap.get(linkKey)?.push(`${key}: ${entry.displayName} (${entry.category})`); - if (entry.path.includes('documentation:')) { + if ('expandGithubDirectory' in entry) { + baseHashes.add(entry.expandGithubDirectory.ref); + } else if (entry.path.includes('documentation:')) { baseHashes.add('documentation:'); } else if (/^https:\/\/raw\.githubusercontent\.com\/patternfly\/[a-zA-Z0-9-]+\//.test(entry.path)) { baseHashes.add(entry.path.split(/\/patternfly\/[a-zA-Z0-9-]+\//)[1]?.split('/')[0]); + } else if (/^https:\/\/raw\.githubusercontent\.com\/project-felt\/[a-zA-Z0-9-]+\//.test(entry.path)) { + baseHashes.add(entry.path.split(/\/project-felt\/[a-zA-Z0-9-]+\//)[1]?.split('/')[0]); } else { baseHashes.add(`new-resource-${entry.path}`); } @@ -67,9 +75,9 @@ describe('docs.json', () => { * If you are updating `docs.json` with an agent confirm altering this value is acceptable * when you open your MR/PR. You may be asked to change your git hash to one of the * existing values and keep this value the same. - * 1 (v6 org) + 1 (v6 react) + 1 (v5 org) + 1 (codemods) + 1 (ai-helpers) + 1 (patternfly-cli) + * 1 (v6 org) + 1 (v6 react) + 1 (v5 org) + 1 (codemods) + 1 (ai-helpers) + 1 (patternfly-cli) + 1 (project-felt/ai-guidelines) */ - expect(baseHashes.size).toBe(6); + expect(baseHashes.size).toBe(7); /** * Confirm total docs count matches metadata diff --git a/src/__tests__/fixtures/githubAiGuidelinesDirectory.ts b/src/__tests__/fixtures/githubAiGuidelinesDirectory.ts new file mode 100644 index 00000000..83f11751 --- /dev/null +++ b/src/__tests__/fixtures/githubAiGuidelinesDirectory.ts @@ -0,0 +1,10 @@ +/** Mirrors GitHub Contents API listing for project-felt/ai-guidelines/content (unit tests + jest.setup). */ +export const GITHUB_AI_GUIDELINES_DIRECTORY_FIXTURE = [ + { name: 'ai-design-principles.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/ai-design-principles.md' }, + { name: 'animation.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/animation.md' }, + { name: 'chatbot-avatars.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/chatbot-avatars.md' }, + { name: 'color.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/color.md' }, + { name: 'iconography.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/iconography.md' }, + { name: 'legal-requirements.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/legal-requirements.md' }, + { name: 'transparency-notices.md', type: 'file', download_url: 'https://raw.githubusercontent.com/project-felt/ai-guidelines/refs/heads/main/content/transparency-notices.md' } +]; diff --git a/src/__tests__/fixtures/mockGithubFetch.ts b/src/__tests__/fixtures/mockGithubFetch.ts new file mode 100644 index 00000000..4c9aeaa1 --- /dev/null +++ b/src/__tests__/fixtures/mockGithubFetch.ts @@ -0,0 +1,36 @@ +import { GITHUB_AI_GUIDELINES_DIRECTORY_FIXTURE } from './githubAiGuidelinesDirectory'; + +const nativeFetch = globalThis.fetch; + +/** + * Mock fetch to intercept GitHub Contents API calls for ai-guidelines. + * Call in beforeAll/beforeEach of tests that trigger catalog expansion. + */ +export const installGithubFetchMock = () => { + globalThis.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : 'url' in input && typeof input.url === 'string' + ? input.url + : String(input); + + if (url.includes('api.github.com/repos/project-felt/ai-guidelines/contents/content')) { + return new Response(JSON.stringify(GITHUB_AI_GUIDELINES_DIRECTORY_FIXTURE), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + if (typeof nativeFetch === 'function') { + return nativeFetch(input as RequestInfo, init); + } + + throw new Error(`Unhandled fetch in test: ${url}`); + }) as typeof fetch; +}; + +export const restoreNativeFetch = () => { + globalThis.fetch = nativeFetch; +}; diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index b3a58179..0029d786 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -1,4 +1,5 @@ import { filterPatternFly, searchPatternFly } from '../patternFly.search'; +import { installGithubFetchMock, restoreNativeFetch } from './fixtures/mockGithubFetch'; describe('filterPatternFly', () => { it.each([ @@ -115,3 +116,40 @@ describe('searchPatternFly', () => { expect(totalPotentialMatches).toBeGreaterThanOrEqual(totalResults); }); }); + +describe('searchPatternFly, AiGuidelines catalog', () => { + beforeAll(() => installGithubFetchMock()); + afterAll(() => restoreNativeFetch()); + const feltPath = (paths: { path?: string }[]) => + paths.some(entry => typeof entry.path === 'string' && entry.path.includes('project-felt/ai-guidelines')); + + it('should exact-match resource aiguidelines for query AiGuidelines', async () => { + const { exactMatches, searchResults } = await searchPatternFly('AiGuidelines'); + + const byName = new Map(searchResults.map(result => [result.name, result])); + const aiGuidelinesResource = byName.get('aiguidelines'); + + expect(aiGuidelinesResource).toBeDefined(); + expect(exactMatches.some(match => match.name === 'aiguidelines')).toBe(true); + expect(feltPath(aiGuidelinesResource!.entries)).toBe(true); + expect(aiGuidelinesResource!.entries.length).toBeGreaterThanOrEqual(7); + }); + + it('should exact-match resource aiguidelines for query aiguidelines', async () => { + const { exactMatches } = await searchPatternFly('aiguidelines'); + + expect(exactMatches.some(match => match.name === 'aiguidelines')).toBe(true); + }); + + it.each([ + { description: 'transparency wording', search: 'transparency notices' }, + { description: 'chatbot avatars display name fragment', search: 'chatbot avatars' }, + { description: 'legal requirements description fragment', search: 'legal review' } + ])('should surface project-felt AiGuidelines URLs when searching $description', async ({ search }) => { + const { searchResults } = await searchPatternFly(search); + + const hitsFelt = searchResults.some(result => feltPath(result.entries)); + + expect(hitsFelt).toBe(true); + }); +}); diff --git a/src/__tests__/resource.patternFlyDocsTemplate.test.ts b/src/__tests__/resource.patternFlyDocsTemplate.test.ts index 48adffd5..2f6a4cfc 100644 --- a/src/__tests__/resource.patternFlyDocsTemplate.test.ts +++ b/src/__tests__/resource.patternFlyDocsTemplate.test.ts @@ -5,6 +5,23 @@ import { resourceCallback } from '../resource.patternFlyDocsTemplate'; import { isPlainObject } from '../server.helpers'; +import { GITHUB_AI_GUIDELINES_DIRECTORY_FIXTURE } from './fixtures/githubAiGuidelinesDirectory'; + +const getFetchUrl = (input: RequestInfo | URL): string => { + if (typeof input === 'string') { + return input; + } + + if (input instanceof URL) { + return input.href; + } + + if (typeof input === 'object' && input !== null && 'url' in input && typeof (input as Request).url === 'string') { + return (input as Request).url; + } + + return String(input); +}; jest.mock('node:fs/promises', () => ({ ...jest.requireActual('node:fs/promises'), @@ -74,10 +91,21 @@ describe('resourceCallback', () => { const mockContent = `Mock content for ${variables.name}`; mockReadFile.mockResolvedValue(mockContent); - mockFetch.mockResolvedValue({ - ok: true, - text: () => mockContent - } as any); + mockFetch.mockImplementation(async (input: RequestInfo | URL, _init?: RequestInit) => { + const urlStr = getFetchUrl(input); + + if (urlStr.includes('api.github.com/repos/project-felt/ai-guidelines')) { + return new Response(JSON.stringify(GITHUB_AI_GUIDELINES_DIRECTORY_FIXTURE), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + return { + ok: true, + text: async () => mockContent + } as Response; + }); const result = await resourceCallback( { href: `patternfly://docs/${variables.version}/${variables.name}` } as any, @@ -149,10 +177,21 @@ describe('resourceCallback', () => { const mockContent = `Mock content for ${variables.name}`; mockReadFile.mockResolvedValue(mockContent); - mockFetch.mockResolvedValue({ - ok: true, - text: () => mockContent - } as any); + mockFetch.mockImplementation(async (input: RequestInfo | URL, _init?: RequestInit) => { + const urlStr = getFetchUrl(input); + + if (urlStr.includes('api.github.com/repos/project-felt/ai-guidelines')) { + return new Response(JSON.stringify(GITHUB_AI_GUIDELINES_DIRECTORY_FIXTURE), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + return { + ok: true, + text: async () => mockContent + } as Response; + }); const uri = new URL('patternfly://docs/test'); diff --git a/src/catalog.expandGithubDirectory.ts b/src/catalog.expandGithubDirectory.ts new file mode 100644 index 00000000..6669506b --- /dev/null +++ b/src/catalog.expandGithubDirectory.ts @@ -0,0 +1,170 @@ +import type { + ExpandGithubDirectoryConfig, + PatternFlyMcpDocsCatalog, + PatternFlyMcpDocsCatalogDoc, + PatternFlyMcpDocsCatalogDocStub, + PatternFlyMcpDocsCatalogEntry, + PatternFlyMcpDocsCatalogSource +} from './docs.embedded'; +import { log } from './logger'; + +type GithubApiContentFile = { + name: string; + type: string; + download_url: string | null; +}; + +const GITHUB_API_USER_AGENT = 'patternfly-mcp-catalog-expansion'; + +/** Repos allowed to use directory expansion (defense in depth). */ +const EXPANSION_ALLOWLIST = new Set(['project-felt/ai-guidelines']); + +const assertExpansionAllowed = (owner: string, repo: string) => { + const slug = `${owner}/${repo}`; + + if (!EXPANSION_ALLOWLIST.has(slug)) { + throw new Error(`catalog: GitHub directory expansion is not enabled for ${slug}`); + } +}; + +const buildGithubContentsApiUrl = (owner: string, repo: string, directoryPath: string, ref: string) => { + const encodedPath = directoryPath.split('/').filter(Boolean).map(encodeURIComponent).join('/'); + const query = new URLSearchParams({ ref }); + + return `https://api.github.com/repos/${owner}/${repo}/contents/${encodedPath}?${query.toString()}`; +}; + +const displayNameFromMarkdownFile = (filename: string, category: string) => { + const base = filename.replace(/\.md$/i, ''); + const words = base.split('-').filter(Boolean).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + const title = words.join(' '); + + return category === 'ai' ? `AI ${title}` : title; +}; + +const pathSlugFromFilename = (filename: string) => filename.replace(/\.md$/i, '').replace(/[^a-zA-Z0-9-]+/g, '-').toLowerCase(); + +/** + * List Markdown files (or files matching `includePattern`) in a GitHub directory via the Contents API. + * + * @param config - GitHub coordinates and optional filename pattern. + */ +const fetchGithubMarkdownFiles = async (config: ExpandGithubDirectoryConfig): Promise => { + assertExpansionAllowed(config.owner, config.repo); + + const url = buildGithubContentsApiUrl(config.owner, config.repo, config.directoryPath, config.ref); + const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': GITHUB_API_USER_AGENT + }; + + if (process.env.GITHUB_TOKEN?.trim()) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN.trim()}`; + } + + const response = await fetch(url, { headers }); + + if (!response.ok) { + const body = await response.text(); + + throw new Error(`catalog: GitHub API ${response.status} for ${url}: ${body.slice(0, 500)}`); + } + + const bodyText = await response.text(); + const data = JSON.parse(bodyText) as unknown; + + if (!Array.isArray(data)) { + throw new Error(`catalog: expected directory listing array from GitHub API for ${url}`); + } + + const includeRe = new RegExp(config.includePattern ?? '\\.md$', 'i'); + + return data.filter((item): item is GithubApiContentFile => { + if (!item || typeof item !== 'object') { + return false; + } + + const file = item as GithubApiContentFile; + + return file.type === 'file' && + typeof file.name === 'string' && + includeRe.test(file.name) && + typeof file.download_url === 'string' && + Boolean(file.download_url); + }); +}; + +/** + * Expand one stub row into concrete catalog docs (one per matched file). + * + * @param template - Catalog stub carrying shared metadata and `expandGithubDirectory`. + * @param config - Same as `template.expandGithubDirectory` (explicit for clarity). + */ +const expandCatalogDoc = async ( + template: PatternFlyMcpDocsCatalogDocStub, + config: ExpandGithubDirectoryConfig +): Promise => { + const files = await fetchGithubMarkdownFiles(config); + + if (files.length === 0) { + log.warn(`catalog: GitHub directory expansion returned no matching files for ${config.owner}/${config.repo}/${config.directoryPath}`); + + return []; + } + + return files.map(file => { + const displayName = displayNameFromMarkdownFile(file.name, template.category); + + return { + displayName, + description: `${template.description} (${file.name})`, + pathSlug: pathSlugFromFilename(file.name), + section: template.section, + category: template.category, + source: template.source, + version: template.version, + path: file.download_url! + }; + }); +}; + +/** + * Replace catalog rows that declare `expandGithubDirectory` with concrete per-file rows. + * Updates `meta.totalDocs` and `meta.totalEntries` to match the expanded structure. + * + * @param catalog - Loaded catalog (e.g. from `docs.json`), possibly containing stubs. + */ +const expandGithubDirectoryInCatalog = async (catalog: PatternFlyMcpDocsCatalogSource): Promise => { + const newDocs: PatternFlyMcpDocsCatalogEntry = {}; + + for (const [resourceKey, entries] of Object.entries(catalog.docs)) { + const expandedEntries: PatternFlyMcpDocsCatalogDoc[] = []; + + for (const entry of entries) { + if (!('expandGithubDirectory' in entry)) { + expandedEntries.push(entry); + continue; + } + + const nested = await expandCatalogDoc(entry, entry.expandGithubDirectory); + + expandedEntries.push(...nested); + } + + newDocs[resourceKey] = expandedEntries; + } + + const totalDocs = Object.values(newDocs).reduce((acc, list) => acc + list.length, 0); + + return { + ...catalog, + docs: newDocs, + meta: { + ...catalog.meta, + totalDocs, + totalEntries: Object.keys(newDocs).length + } + }; +}; + +export { expandGithubDirectoryInCatalog }; diff --git a/src/docs.embedded.ts b/src/docs.embedded.ts index 6c09d303..c24d9707 100644 --- a/src/docs.embedded.ts +++ b/src/docs.embedded.ts @@ -1,5 +1,16 @@ /** - * PatternFly JSON catalog doc + * Expand a GitHub directory into one catalog row per file (GitHub Contents API at runtime). + */ +type ExpandGithubDirectoryConfig = { + owner: string; + repo: string; + ref: string; + directoryPath: string; + includePattern?: string; +}; + +/** + * Resolved catalog row (concrete documentation URL). */ type PatternFlyMcpDocsCatalogDoc = { displayName: string; @@ -13,12 +24,35 @@ type PatternFlyMcpDocsCatalogDoc = { }; /** - * PatternFly JSON catalog documentation entries. + * Stub row expanded at startup via {@link ExpandGithubDirectoryConfig}. + */ +type PatternFlyMcpDocsCatalogDocStub = { + displayName: string; + description: string; + pathSlug: string; + section: string; + category: string; + source: string; + version: string; + expandGithubDirectory: ExpandGithubDirectoryConfig; +}; + +type PatternFlyMcpDocsCatalogDocImport = PatternFlyMcpDocsCatalogDoc | PatternFlyMcpDocsCatalogDocStub; + +/** + * PatternFly JSON catalog documentation entries (resolved). */ type PatternFlyMcpDocsCatalogEntry = { [key: string]: PatternFlyMcpDocsCatalogDoc[] }; +/** + * Catalog entries as stored in `docs.json` (may include expansion stubs). + */ +type PatternFlyMcpDocsCatalogEntryImport = { + [key: string]: PatternFlyMcpDocsCatalogDocImport[] +}; + /** * PatternFly documentation catalog. * @@ -39,6 +73,16 @@ interface PatternFlyMcpDocsCatalog { docs: PatternFlyMcpDocsCatalogEntry } +/** + * Shape of `docs.json` on disk (may include GitHub directory expansion stubs). + */ +interface PatternFlyMcpDocsCatalogSource { + version?: string; + generated?: string; + meta: PatternFlyMcpDocsCatalog['meta']; + docs: PatternFlyMcpDocsCatalogEntryImport; +} + /** * Fallback documentation for when the catalog is unavailable. * Points to the high-level entry points for PatternFly. @@ -109,7 +153,12 @@ const EMBEDDED_DOCS: PatternFlyMcpDocsCatalog = { export { EMBEDDED_DOCS, + type ExpandGithubDirectoryConfig, type PatternFlyMcpDocsCatalog, + type PatternFlyMcpDocsCatalogDoc, + type PatternFlyMcpDocsCatalogDocImport, + type PatternFlyMcpDocsCatalogDocStub, type PatternFlyMcpDocsCatalogEntry, - type PatternFlyMcpDocsCatalogDoc + type PatternFlyMcpDocsCatalogEntryImport, + type PatternFlyMcpDocsCatalogSource }; diff --git a/src/docs.json b/src/docs.json index f907b92b..7e4177cd 100644 --- a/src/docs.json +++ b/src/docs.json @@ -1,9 +1,9 @@ { "version": "1", - "generated": "2026-04-29T20:24:40.471Z", + "generated": "2026-05-05T00:00:00.000Z", "meta": { - "totalEntries": 130, - "totalDocs": 324, + "totalEntries": 131, + "totalDocs": 325, "source": "patternfly-mcp-internal" }, "docs": { @@ -3506,6 +3506,23 @@ "path": "https://raw.githubusercontent.com/patternfly/ai-helpers/aa2766e8d9cb2bc08c13e41106d75c9829bc001f/CONTRIBUTING.md", "version": "v6" } + ], + "AiGuidelines": [ + { + "displayName": "AI Guidelines (GitHub directory)", + "description": "project-felt/ai-guidelines", + "pathSlug": "ai-guidelines-directory", + "section": "guidelines", + "category": "ai", + "source": "github", + "version": "v6", + "expandGithubDirectory": { + "owner": "project-felt", + "repo": "ai-guidelines", + "ref": "main", + "directoryPath": "content" + } + } ] } } diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 369ee198..57ad763d 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -477,7 +477,9 @@ const PATTERNFLY_OPTIONS: PatternFlyOptions = { urlWhitelist: [ 'https://patternfly.org', 'https://github.com/patternfly', - 'https://raw.githubusercontent.com/patternfly' + 'https://raw.githubusercontent.com/patternfly', + 'https://github.com/project-felt', + 'https://raw.githubusercontent.com/project-felt' ], urlWhitelistProtocols: ['http', 'https'] }; diff --git a/src/patternFly.getResources.ts b/src/patternFly.getResources.ts index c53b5a88..c2f03a93 100644 --- a/src/patternFly.getResources.ts +++ b/src/patternFly.getResources.ts @@ -12,14 +12,16 @@ import { log, formatUnknownError } from './logger'; import { EMBEDDED_DOCS, type PatternFlyMcpDocsCatalog, + type PatternFlyMcpDocsCatalogDoc, type PatternFlyMcpDocsCatalogEntry, - type PatternFlyMcpDocsCatalogDoc + type PatternFlyMcpDocsCatalogSource } from './docs.embedded'; import { INDEX_BLOCKLIST_WORDS, INDEX_EXCEPTION_WORDS, INDEX_NOISE_WORDS } from './docs.filterWords'; +import { expandGithubDirectoryInCatalog } from './catalog.expandGithubDirectory'; /** * Derive the component schema type from @patternfly/patternfly-component-schemas @@ -178,21 +180,53 @@ interface PatternFlyMcpAvailableResources extends PatternFlyVersionContext { * @returns PatternFly documentation catalog JSON, or fallback catalog if import fails. */ const getPatternFlyDocsCatalog = async (): Promise => { - let docsCatalog = EMBEDDED_DOCS; + let docsCatalog: PatternFlyMcpDocsCatalogSource = EMBEDDED_DOCS; let isFallback = false; try { if (process.env.NODE_ENV === 'local') { - docsCatalog = (await import('./docs.json', { with: { type: 'json' } })).default; + docsCatalog = (await import('./docs.json', { with: { type: 'json' } })).default as PatternFlyMcpDocsCatalogSource; } else { - docsCatalog = (await import('#docsCatalog', { with: { type: 'json' } })).default; + docsCatalog = (await import('#docsCatalog', { with: { type: 'json' } })).default as PatternFlyMcpDocsCatalogSource; } } catch (error) { isFallback = true; log.debug(`Failed to import docs catalog '#docsCatalog': ${formatUnknownError(error)}`, 'Using fallback docs catalog.'); } - return { ...docsCatalog, isFallback }; + if (isFallback) { + return { ...EMBEDDED_DOCS, isFallback }; + } + + try { + const resolvedCatalog = await expandGithubDirectoryInCatalog(docsCatalog); + + return { ...resolvedCatalog, isFallback }; + } catch (error) { + log.error(`Failed to expand GitHub directory catalog entries: ${formatUnknownError(error)}`, 'Serving catalog without expanded entries.'); + + // Strip stubs that have no `path` so downstream code doesn't break + const safeDocs: PatternFlyMcpDocsCatalogEntry = {}; + + for (const [key, entries] of Object.entries(docsCatalog.docs)) { + const concrete = entries.filter((entry): entry is PatternFlyMcpDocsCatalogDoc => 'path' in entry && typeof entry.path === 'string'); + + if (concrete.length > 0) { + safeDocs[key] = concrete; + } + } + + return { + ...docsCatalog, + docs: safeDocs, + meta: { + ...docsCatalog.meta, + totalDocs: Object.values(safeDocs).reduce((acc, list) => acc + list.length, 0), + totalEntries: Object.keys(safeDocs).length + }, + isFallback: true + }; + } }; /** diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index 46d869f1..bbe41149 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -120,7 +120,7 @@ const resourceCallback = async (passedUri: URL, variables: Record entry.path).filter(Boolean); + const matchedUrls = byEntry.map(entry => entry.path).filter((path): path is string => typeof path === 'string' && path.length > 0); if (matchedUrls.length > 0) { const processedDocs = await processDocsFunction.memo(matchedUrls); diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index 9340375d..9c4ab63f 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -110,7 +110,7 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { ErrorCode.InvalidParams ); - updatedUrlList.push(...exactMatches.flatMap(match => match.entries.map(entry => entry.path)).filter(Boolean)); + updatedUrlList.push(...exactMatches.flatMap(match => match.entries.map(entry => entry.path)).filter((path): path is string => typeof path === 'string' && path.length > 0)); } const docs: ProcessedDoc[] = []; diff --git a/tests/e2e/__snapshots__/httpTransport.test.ts.snap b/tests/e2e/__snapshots__/httpTransport.test.ts.snap index 46e46f49..66032946 100644 --- a/tests/e2e/__snapshots__/httpTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/httpTransport.test.ts.snap @@ -31,7 +31,7 @@ Use these parameters to filter the PatternFly documentation index. | Parameter | Valid Values | Description | | :--- | :--- | :--- | -| \`category\` | \`accessibility\`, \`design-guidelines\`, \`design-tokens\`, \`development-guidelines\`, \`grammar\`, \`react\`, \`writing-guides\` | Filter by category | +| \`category\` | \`accessibility\`, \`ai\`, \`design-guidelines\`, \`design-tokens\`, \`development-guidelines\`, \`grammar\`, \`react\`, \`writing-guides\` | Filter by category | | \`section\` | \`ai\`, \`charts\`, \`components\`, \`content-design\`, \`extensions\`, \`foundations-and-styles\`, \`get-started\`, \`guidelines\`, \`layouts\`, \`patterns\`, \`resources\`, \`upgrade\` | Filter by section | | \`version\` | \`v4\`, \`v5\`, \`v6\` | Filter by version | diff --git a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap index 098cc273..4cd2b340 100644 --- a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap @@ -31,7 +31,7 @@ Use these parameters to filter the PatternFly documentation index. | Parameter | Valid Values | Description | | :--- | :--- | :--- | -| \`category\` | \`accessibility\`, \`design-guidelines\`, \`design-tokens\`, \`development-guidelines\`, \`grammar\`, \`react\`, \`writing-guides\` | Filter by category | +| \`category\` | \`accessibility\`, \`ai\`, \`design-guidelines\`, \`design-tokens\`, \`development-guidelines\`, \`grammar\`, \`react\`, \`writing-guides\` | Filter by category | | \`section\` | \`ai\`, \`charts\`, \`components\`, \`content-design\`, \`extensions\`, \`foundations-and-styles\`, \`get-started\`, \`guidelines\`, \`layouts\`, \`patterns\`, \`resources\`, \`upgrade\` | Filter by section | | \`version\` | \`v4\`, \`v5\`, \`v6\` | Filter by version | @@ -147,36 +147,21 @@ exports[`Logging should allow setting logging options, default 1`] = `[]`; 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-components-index-meta -", - "[INFO]: Registered resource: patternfly-components-index -", - "[INFO]: Registered resource: patternfly-docs-index-meta -", - "[INFO]: Registered resource: patternfly-docs-index -", - "[INFO]: Registered resource: patternfly-docs-template -", - "[INFO]: Registered resource: patternfly-schemas-index-meta -", - "[INFO]: Registered resource: patternfly-schemas-index -", - "[INFO]: Registered resource: patternfly-schemas-template -", - "[INFO]: Registered tool: usePatternFlyDocs -", - "[INFO]: Registered tool: searchPatternFlyDocs -", - "[INFO]: @patternfly/patternfly-mcp server running on stdio transport +[INFO]: Server stats enabled. +[INFO]: No external resources loaded. +[INFO]: No external tools loaded. +[INFO]: Registered resource: patternfly-context +[INFO]: Registered resource: patternfly-components-index-meta +[INFO]: Registered resource: patternfly-components-index +[INFO]: Registered resource: patternfly-docs-index-meta +[INFO]: Registered resource: patternfly-docs-index +[INFO]: Registered resource: patternfly-docs-template +[INFO]: Registered resource: patternfly-schemas-index-meta +[INFO]: Registered resource: patternfly-schemas-index +[INFO]: Registered resource: patternfly-schemas-template +[INFO]: Registered tool: usePatternFlyDocs +[INFO]: Registered tool: searchPatternFlyDocs +[INFO]: @patternfly/patternfly-mcp server running on stdio transport ", ] `; diff --git a/tests/e2e/utils/stdioTransportClient.ts b/tests/e2e/utils/stdioTransportClient.ts index c6ef5812..1b3a439e 100644 --- a/tests/e2e/utils/stdioTransportClient.ts +++ b/tests/e2e/utils/stdioTransportClient.ts @@ -233,7 +233,7 @@ export const startServer = async ({ }, logs: () => [ - ...stderrLogs, + ...(stderrLogs.length > 0 ? [stderrLogs.join('')] : []), ...protocolLogs ], stderrLogs: () => stderrLogs.slice(),