From d5fc18bdfd3ab7839b0360b8080f249d2647a458 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Wed, 6 May 2026 12:38:07 -0400 Subject: [PATCH] feat: add dynamic ai-helpers skill fetching from GitHub Add useAiHelpersSkill MCP tool and resource templates that fetch skills from ai-helpers at runtime. Disk cache fallback when GitHub is unreachable. Ref: PF-4034 --- .gitignore | 3 + cspell.config.json | 2 + .../__snapshots__/server.test.ts.snap | 74 ++++++++++ src/aiHelpers.skills.ts | 129 ++++++++++++++++++ src/resource.aiHelpersSkillsIndex.ts | 59 ++++++++ src/resource.aiHelpersSkillsTemplate.ts | 110 +++++++++++++++ src/server.ts | 10 +- src/tool.aiHelpersSkills.ts | 69 ++++++++++ .../__snapshots__/httpTransport.test.ts.snap | 35 ++--- .../__snapshots__/stdioTransport.test.ts.snap | 7 + 10 files changed, 479 insertions(+), 19 deletions(-) create mode 100644 src/aiHelpers.skills.ts create mode 100644 src/resource.aiHelpersSkillsIndex.ts create mode 100644 src/resource.aiHelpersSkillsTemplate.ts create mode 100644 src/tool.aiHelpersSkills.ts diff --git a/.gitignore b/.gitignore index 1c6f4ac6..db0c5f55 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ dist/ build/ *.tsbuildinfo +# Runtime cache +.cache/ + # Environment variables .env .env.local diff --git a/cspell.config.json b/cspell.config.json index bc46682e..fa03261e 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -1,6 +1,7 @@ { "language": "en", "words": [ + "aihelpers", "amet", "codemods", "ized", @@ -12,6 +13,7 @@ "onsessioninitialized", "onsessionclosed", "patternfly", + "prerendered", "rereview", "rsort", "sparkline", diff --git a/src/__tests__/__snapshots__/server.test.ts.snap b/src/__tests__/__snapshots__/server.test.ts.snap index 03eb8532..f5aaa574 100644 --- a/src/__tests__/__snapshots__/server.test.ts.snap +++ b/src/__tests__/__snapshots__/server.test.ts.snap @@ -42,12 +42,21 @@ exports[`runServer should allow server to be stopped, http stop server: diagnost [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ "Registered tool: searchPatternFlyDocs", ], + [ + "Registered tool: useAiHelpersSkill", + ], [ "test-server server running on HTTP transport", ], @@ -104,12 +113,21 @@ exports[`runServer should allow server to be stopped, stdio stop server: diagnos [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ "Registered tool: searchPatternFlyDocs", ], + [ + "Registered tool: useAiHelpersSkill", + ], [ "test-server server running on stdio transport", ], @@ -166,6 +184,12 @@ exports[`runServer should attempt to run server, create transport, connect, and [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "test-server-4 server running on stdio transport", ], @@ -239,6 +263,12 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "test-server-7 server running on stdio transport", ], @@ -307,6 +337,12 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "test-server-8 server running on stdio transport", ], @@ -380,6 +416,12 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1` [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "Registered tool: loremIpsum", ], @@ -461,6 +503,12 @@ exports[`runServer should attempt to run server, register multiple tools: diagno [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "Registered tool: loremIpsum", ], @@ -549,6 +597,12 @@ exports[`runServer should attempt to run server, use custom options: diagnostics [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "test-server-3 server running on stdio transport", ], @@ -622,12 +676,21 @@ exports[`runServer should attempt to run server, use default tools, http: diagno [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ "Registered tool: searchPatternFlyDocs", ], + [ + "Registered tool: useAiHelpersSkill", + ], [ "test-server-2 server running on HTTP transport", ], @@ -658,6 +721,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno "registerTool": [ "usePatternFlyDocs", "searchPatternFlyDocs", + "useAiHelpersSkill", ], } `; @@ -704,12 +768,21 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn [ "Registered resource: patternfly-schemas-template", ], + [ + "Registered resource: aihelpers-skills-index", + ], + [ + "Registered resource: aihelpers-skills-template", + ], [ "Registered tool: usePatternFlyDocs", ], [ "Registered tool: searchPatternFlyDocs", ], + [ + "Registered tool: useAiHelpersSkill", + ], [ "test-server-1 server running on stdio transport", ], @@ -740,6 +813,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn "registerTool": [ "usePatternFlyDocs", "searchPatternFlyDocs", + "useAiHelpersSkill", ], } `; diff --git a/src/aiHelpers.skills.ts b/src/aiHelpers.skills.ts new file mode 100644 index 00000000..db078730 --- /dev/null +++ b/src/aiHelpers.skills.ts @@ -0,0 +1,129 @@ +import { resolve, dirname } from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { memo } from './server.caching'; +import { log } from './logger'; + +interface AiHelpersSkill { + name: string; + plugin: string; + description: string; + content: string; +} + +interface AiHelpersSkillsData { + version: string; + generated: string; + meta: { + totalSkills: number; + source: string; + }; + skills: AiHelpersSkill[]; +} + +const SKILLS_URL = 'https://raw.githubusercontent.com/patternfly/ai-helpers/main/dist/skills.json'; + +const FETCH_TIMEOUT_MS = 10_000; + +const CACHE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.cache'); + +const CACHE_FILE = resolve(CACHE_DIR, 'aiHelpers.skills.json'); + +const isValidSkillsData = (data: unknown): data is AiHelpersSkillsData => + Boolean(data) && typeof data === 'object' && Array.isArray((data as AiHelpersSkillsData).skills); + +const readCachedSkills = async (): Promise => { + try { + const raw = await readFile(CACHE_FILE, 'utf-8'); + const data = JSON.parse(raw); + + if (isValidSkillsData(data)) { + return data; + } + } catch { + // No cache file or invalid — expected on first run + } + + return undefined; +}; + +const writeCachedSkills = async (data: AiHelpersSkillsData): Promise => { + try { + await mkdir(CACHE_DIR, { recursive: true }); + await writeFile(CACHE_FILE, JSON.stringify(data), 'utf-8'); + } catch (error) { + log.warn(`Failed to write skills cache: ${error instanceof Error ? error.message : error}`); + } +}; + +const fetchSkillsData = async (): Promise => { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch(SKILLS_URL, { signal: controller.signal }); + + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json() as AiHelpersSkillsData; + + if (!isValidSkillsData(data)) { + throw new Error('Invalid skills data shape'); + } + + log.info(`Loaded ${data.skills.length} ai-helpers skills from GitHub (generated ${data.generated})`); + + await writeCachedSkills(data); + + return data; + } catch (error) { + log.warn(`Failed to fetch ai-helpers skills from GitHub: ${error instanceof Error ? error.message : error}`); + + const cached = await readCachedSkills(); + + if (cached) { + log.info(`Using cached skills data (generated ${cached.generated})`); + + return cached; + } + + log.warn('No cached skills available — skills will be empty until GitHub is reachable'); + + return { version: '0', generated: '', meta: { totalSkills: 0, source: 'none' }, skills: [] }; + } +}; + +const fetchSkillsDataMemo = memo(fetchSkillsData, { + cacheLimit: 1, + expire: 5 * 60 * 1000, + cacheErrors: false +}); + +/** + * Returns all ai-helpers skills, fetched from GitHub with disk cache fallback. + */ +const getAiHelpersSkills = async (): Promise => { + const data = await fetchSkillsDataMemo(); + + return data.skills; +}; + +/** + * Returns the full SKILL.md content for a given skill name. + * + * @param name - The skill name to look up. + */ +const getAiHelpersSkillContent = async (name: string): Promise => { + const data = await fetchSkillsDataMemo(); + const skill = data.skills.find( + (entry: AiHelpersSkill) => entry.name.toLowerCase() === name.toLowerCase() + ); + + return skill?.content; +}; + +export { getAiHelpersSkills, getAiHelpersSkillContent, type AiHelpersSkill }; diff --git a/src/resource.aiHelpersSkillsIndex.ts b/src/resource.aiHelpersSkillsIndex.ts new file mode 100644 index 00000000..1f787360 --- /dev/null +++ b/src/resource.aiHelpersSkillsIndex.ts @@ -0,0 +1,59 @@ +import { type McpResource } from './server'; +import { stringJoin } from './server.helpers'; +import { getAiHelpersSkills } from './aiHelpers.skills'; + +/** + * Name of the resource. + */ +const NAME = 'aihelpers-skills-index'; + +/** + * URI for the resource. + */ +const URI = 'aihelpers://skills/index'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'ai-helpers Skills Index', + description: 'Lists all available ai-helpers skills with names and descriptions.', + mimeType: 'text/markdown' as const +}; + +/** + * Resource creator for the ai-helpers skills index. + */ +const aiHelpersSkillsIndexResource = (): McpResource => [ + NAME, + URI, + CONFIG, + async (passedUri: URL) => { + const skills = await getAiHelpersSkills(); + + const header = stringJoin.newline( + '# ai-helpers Skills', + '', + 'PatternFly coding skills served from the [ai-helpers](https://github.com/patternfly/ai-helpers) marketplace. Read any skill via `aihelpers://skills/{name}`.', + '' + ); + + const table = stringJoin.newline( + '| Skill | Plugin | Description |', + '|-------|--------|-------------|', + ...skills.map(skill => `| ${skill.name} | ${skill.plugin} | ${skill.description} |`) + ); + + return { + contents: [ + { + uri: passedUri?.toString(), + mimeType: 'text/markdown', + text: stringJoin.newline(header, table) + } + ] + }; + } +]; + +export { aiHelpersSkillsIndexResource, NAME, URI, CONFIG }; diff --git a/src/resource.aiHelpersSkillsTemplate.ts b/src/resource.aiHelpersSkillsTemplate.ts new file mode 100644 index 00000000..7e64de26 --- /dev/null +++ b/src/resource.aiHelpersSkillsTemplate.ts @@ -0,0 +1,110 @@ +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { type McpResource, type McpResourceMetadata } from './server'; +import { memo } from './server.caching'; +import { assertInputStringLength } from './server.assertions'; +import { getOptions } from './options.context'; +import { getAiHelpersSkills, getAiHelpersSkillContent } from './aiHelpers.skills'; + +/** + * Name of the resource template. + */ +const NAME = 'aihelpers-skills-template'; + +/** + * URI template for the resource. + */ +const URI_TEMPLATE = 'aihelpers://skills/{name}'; + +/** + * Resource configuration. + */ +const CONFIG = { + title: 'ai-helpers Skill', + description: `Retrieve the full content of a specific ai-helpers skill by name. ${URI_TEMPLATE}`, + mimeType: 'text/markdown' as const +}; + +/** + * Name completion callback for the URI template. + * + * @param value - The partial name to match against. + */ +const uriNameComplete = async (value: string) => { + const skills = await getAiHelpersSkills(); + const lower = (value || '').toLowerCase(); + + return skills + .map(skill => skill.name) + .filter(name => name.toLowerCase().includes(lower)) + .sort(); +}; + +uriNameComplete.memo = memo(uriNameComplete); + +/** + * Resource callback. + * + * @param passedUri - The resolved URI for this resource request. + * @param variables - The URI template variables extracted from the request. + * @param options - Server options (defaults to current context options). + */ +const resourceCallback = async (passedUri: URL, variables: Record, options = getOptions()) => { + const { name } = variables || {}; + + assertInputStringLength(name, { + ...options.minMax.inputStrings, + inputDisplayName: 'name' + }); + + const skillName = Array.isArray(name) ? name[0] : name; + const content = await getAiHelpersSkillContent(skillName); + + if (!content) { + return { + contents: [ + { + uri: passedUri?.toString(), + mimeType: 'text/markdown', + text: `Skill "${skillName}" not found. Use \`aihelpers://skills/index\` to list available skills.` + } + ] + }; + } + + return { + contents: [ + { + uri: passedUri?.toString(), + mimeType: 'text/markdown', + text: content + } + ] + }; +}; + +/** + * Metadata for the resource. + */ +const METADATA: McpResourceMetadata = { + complete: { + name: uriNameComplete.memo + } +}; + +/** + * Resource creator for individual ai-helpers skills. + */ +const aiHelpersSkillsTemplateResource = (): McpResource => [ + NAME, + new ResourceTemplate(URI_TEMPLATE, { + list: undefined, + complete: { + name: uriNameComplete.memo + } + }), + CONFIG, + resourceCallback, + METADATA +]; + +export { aiHelpersSkillsTemplateResource, NAME, URI_TEMPLATE, CONFIG }; diff --git a/src/server.ts b/src/server.ts index 0ec3e1a7..54a9b0ff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,9 @@ import { patternFlyDocsIndexResource } from './resource.patternFlyDocsIndex'; import { patternFlyDocsTemplateResource } from './resource.patternFlyDocsTemplate'; import { patternFlySchemasIndexResource } from './resource.patternFlySchemasIndex'; import { patternFlySchemasTemplateResource } from './resource.patternFlySchemasTemplate'; +import { aiHelpersSkillsIndexResource } from './resource.aiHelpersSkillsIndex'; +import { aiHelpersSkillsTemplateResource } from './resource.aiHelpersSkillsTemplate'; +import { useAiHelpersSkillTool } from './tool.aiHelpersSkills'; import { startHttpTransport, type HttpServerHandle } from './server.http'; import { memo } from './server.caching'; import { log, type LogEvent } from './logger'; @@ -208,7 +211,8 @@ interface ServerInstance { */ const builtinTools: McpToolCreator[] = [ usePatternFlyDocsTool, - searchPatternFlyDocsTool + searchPatternFlyDocsTool, + useAiHelpersSkillTool ]; /** @@ -222,7 +226,9 @@ const builtinResources: McpResourceCreator[] = [ patternFlyDocsIndexResource, patternFlyDocsTemplateResource, patternFlySchemasIndexResource, - patternFlySchemasTemplateResource + patternFlySchemasTemplateResource, + aiHelpersSkillsIndexResource, + aiHelpersSkillsTemplateResource ]; /** diff --git a/src/tool.aiHelpersSkills.ts b/src/tool.aiHelpersSkills.ts new file mode 100644 index 00000000..84899893 --- /dev/null +++ b/src/tool.aiHelpersSkills.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { type McpTool } from './server'; +import { getOptions } from './options.context'; +import { getAiHelpersSkills, getAiHelpersSkillContent } from './aiHelpers.skills'; +import { stringJoin } from './server.helpers'; + +/** + * useAiHelpersSkill tool function. + * + * @param options + * @returns MCP tool tuple [name, schema, callback] + */ +const useAiHelpersSkillTool = (options = getOptions()): McpTool => { + const callback = async (args: any = {}) => { + const { name } = args; + + if (!name) { + const skills = await getAiHelpersSkills(); + const text = stringJoin.newline( + '# ai-helpers Skills', + '', + `${skills.length} skills available. Pass a skill name to retrieve its full content.`, + '', + '| Skill | Plugin | Description |', + '|-------|--------|-------------|', + ...skills.map(skill => `| ${skill.name} | ${skill.plugin} | ${skill.description} |`) + ); + + return { content: [{ type: 'text', text }] }; + } + + const content = await getAiHelpersSkillContent(name); + + if (!content) { + const skills = await getAiHelpersSkills(); + const suggestions = skills + .filter(skill => skill.name.toLowerCase().includes(name.toLowerCase())) + .map(skill => skill.name); + const text = suggestions.length + ? `Skill "${name}" not found. Did you mean: ${suggestions.join(', ')}?` + : `Skill "${name}" not found. Use this tool without a name to list all available skills.`; + + return { content: [{ type: 'text', text }] }; + } + + return { content: [{ type: 'text', text: content }] }; + }; + + return [ + 'useAiHelpersSkill', + { + description: 'Look up coding skills for PatternFly React development, testing, accessibility, and design foundations. Call without arguments to list all available skills, or pass a skill name to get full instructions.', + inputSchema: { + name: z.string() + .max(options.minMax.inputStrings.max) + .optional() + .describe('Skill name to retrieve (e.g. "pf-unit-test-generator"). Omit to list all skills.') + } + }, + callback + ]; +}; + +/** + * A tool name, typically the first entry in the tuple. Used in logging and deduplication. + */ +useAiHelpersSkillTool.toolName = 'useAiHelpersSkill'; + +export { useAiHelpersSkillTool }; diff --git a/tests/e2e/__snapshots__/httpTransport.test.ts.snap b/tests/e2e/__snapshots__/httpTransport.test.ts.snap index 46e46f49..f5837ea8 100644 --- a/tests/e2e/__snapshots__/httpTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/httpTransport.test.ts.snap @@ -69,6 +69,16 @@ Use these parameters to filter the list of PatternFly component schemas. } `; +exports[`Builtin tools, HTTP transport should expose expected tools and stable shape: tools 1`] = ` +{ + "toolNames": [ + "searchPatternFlyDocs", + "useAiHelpersSkill", + "usePatternFlyDocs", + ], +} +`; + exports[`Builtin tools, HTTP transport should concatenate headers and separator with two remote files 1`] = ` "# Content for https://www.patternfly.org/notARealPath/AboutModal.md Source: https://www.patternfly.org/notARealPath/AboutModal.md @@ -87,23 +97,6 @@ Source: https://www.patternfly.org/notARealPath/ChartLegend.md This is a test document for mocking remote HTTP requests." `; -exports[`Builtin tools, HTTP transport should expose expected tools and stable shape: tools 1`] = ` -{ - "toolNames": [ - "searchPatternFlyDocs", - "usePatternFlyDocs", - ], -} -`; - -exports[`Builtin tools, HTTP transport should initialize MCP session over HTTP 1`] = ` -{ - "baseUrl": "http://127.0.0.1:8000", - "name": "@patternfly/patternfly-mcp", - "version": "2024-11-05", -} -`; - exports[`Builtin tools, HTTP transport should return expected markdown structure for search results: markdown 1`] = ` "# Search results for PatternFly version "v6" and "button". Showing 2 exact matches. 1. **button**: @@ -141,3 +134,11 @@ exports[`Builtin tools, HTTP transport should return expected markdown structure - Use the "usePatternFlyDocs" tool with the above names and URLs to fetch resource content. - Use a search all ("*") to find all available resources." `; + +exports[`Builtin tools, HTTP transport should initialize MCP session over HTTP 1`] = ` +{ + "baseUrl": "http://127.0.0.1:8000", + "name": "@patternfly/patternfly-mcp", + "version": "2024-11-05", +} +`; diff --git a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap index 098cc273..ed41f88a 100644 --- a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap @@ -99,6 +99,7 @@ exports[`Builtin tools, STDIO should expose expected tools and stable shape 1`] { "toolNames": [ "searchPatternFlyDocs", + "useAiHelpersSkill", "usePatternFlyDocs", ], } @@ -171,10 +172,16 @@ exports[`Logging should allow setting logging options, stderr 1`] = ` "[INFO]: Registered resource: patternfly-schemas-index ", "[INFO]: Registered resource: patternfly-schemas-template +", + "[INFO]: Registered resource: aihelpers-skills-index +", + "[INFO]: Registered resource: aihelpers-skills-template ", "[INFO]: Registered tool: usePatternFlyDocs ", "[INFO]: Registered tool: searchPatternFlyDocs +", + "[INFO]: Registered tool: useAiHelpersSkill ", "[INFO]: @patternfly/patternfly-mcp server running on stdio transport ",