Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ dist/
build/
*.tsbuildinfo

# Runtime cache
.cache/

# Environment variables
.env
.env.local
Expand Down
2 changes: 2 additions & 0 deletions cspell.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"language": "en",
"words": [
"aihelpers",
"amet",
"codemods",
"ized",
Expand All @@ -12,6 +13,7 @@
"onsessioninitialized",
"onsessionclosed",
"patternfly",
"prerendered",
"rereview",
"rsort",
"sparkline",
Expand Down
74 changes: 74 additions & 0 deletions src/__tests__/__snapshots__/server.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -658,6 +721,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno
"registerTool": [
"usePatternFlyDocs",
"searchPatternFlyDocs",
"useAiHelpersSkill",
],
}
`;
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -740,6 +813,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn
"registerTool": [
"usePatternFlyDocs",
"searchPatternFlyDocs",
"useAiHelpersSkill",
],
}
`;
Expand Down
129 changes: 129 additions & 0 deletions src/aiHelpers.skills.ts
Original file line number Diff line number Diff line change
@@ -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<AiHelpersSkillsData | undefined> => {
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<void> => {
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<AiHelpersSkillsData> => {
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<AiHelpersSkill[]> => {
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<string | undefined> => {
const data = await fetchSkillsDataMemo();
const skill = data.skills.find(
(entry: AiHelpersSkill) => entry.name.toLowerCase() === name.toLowerCase()
);

return skill?.content;
};

export { getAiHelpersSkills, getAiHelpersSkillContent, type AiHelpersSkill };
59 changes: 59 additions & 0 deletions src/resource.aiHelpersSkillsIndex.ts
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading