diff --git a/CLAUDE.md b/CLAUDE.md index aed774fd..e8191414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ Located in `/libs/*`: - **sdk** (`libs/sdk`) - Core FrontMCP SDK - **adapters** (`libs/adapters`) - Framework adapters and integrations - **plugins** (`libs/plugins`) - Plugin system and extensions +- **skills** (`libs/skills`) - Curated SKILL.md catalog for scaffold and install tooling > **Note:** `ast-guard`, `vectoriadb`, `enclave-vm`, `json-schema-to-zod-v3`, and `mcp-from-openapi` have been moved to external repositories. @@ -83,6 +84,15 @@ export * from './errors'; - **Scope**: Standalone auth library used by SDK and other packages - **Note**: All authentication-related code should be placed in this library, not in SDK +#### @frontmcp/skills + +- **Purpose**: Curated SKILL.md catalog for scaffolding and future skill installation +- **Scope**: Publishable catalog of markdown-based skills organized by category and deployment target +- **Structure**: `catalog/` contains SKILL.md directories; `src/` has manifest types and loader helpers +- **Build**: Custom asset-aware build that copies `catalog/**` into dist (not stock Nx lib generator) +- **Manifest**: `catalog/skills-manifest.json` is the single source of truth for scaffold filtering and future installer +- **Adding skills**: Create dir in `catalog///`, add `SKILL.md`, update manifest, run `nx test skills` + ### Demo Applications #### demo (`apps/demo`) diff --git a/apps/e2e/demo-e2e-cli-exec/e2e/cli-skills.e2e.spec.ts b/apps/e2e/demo-e2e-cli-exec/e2e/cli-skills.e2e.spec.ts new file mode 100644 index 00000000..b0c2de38 --- /dev/null +++ b/apps/e2e/demo-e2e-cli-exec/e2e/cli-skills.e2e.spec.ts @@ -0,0 +1,169 @@ +/** + * E2E tests for `frontmcp skills` CLI commands: list, search, install. + * + * Runs the actual frontmcp CLI binary against the real @frontmcp/skills catalog. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runFrontmcpCli } from './helpers/exec-cli'; + +describe('CLI Skills Commands', () => { + // ─── skills list ──────────────────────────────────────────────────────────── + + describe('skills list', () => { + it('should list all skills with exit code 0', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'list']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Skills Catalog'); + }); + + it('should include known skill names', () => { + const { stdout } = runFrontmcpCli(['skills', 'list']); + expect(stdout).toContain('frontmcp-setup'); + expect(stdout).toContain('frontmcp-deployment'); + expect(stdout).toContain('frontmcp-development'); + }); + + it('should filter by category', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'list', '--category', 'setup']); + expect(exitCode).toBe(0); + expect(stdout).toContain('frontmcp-setup'); + // Should NOT include deployment skills + expect(stdout).not.toContain('frontmcp-deployment'); + expect(stdout).not.toContain('frontmcp-development'); + }); + + it('should filter by tag', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'list', '--tag', 'redis']); + expect(exitCode).toBe(0); + expect(stdout).toContain('frontmcp-setup'); + }); + + it('should filter by bundle', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'list', '--bundle', 'minimal']); + expect(exitCode).toBe(0); + expect(stdout).toContain('frontmcp-setup'); + // Skills not in minimal bundle should be excluded + expect(stdout).not.toContain('frontmcp-guides'); + }); + }); + + // ─── skills search ────────────────────────────────────────────────────────── + + describe('skills search', () => { + it('should return results for a keyword query', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'search', 'redis']); + expect(exitCode).toBe(0); + expect(stdout).toContain('frontmcp-config'); + expect(stdout).toContain('result(s)'); + }); + + it('should return results for a multi-word query', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'search', 'deploy serverless']); + expect(exitCode).toBe(0); + // Should match the deployment router + expect(stdout).toContain('frontmcp-deployment'); + }); + + it('should respect --limit option', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'search', 'setup', '--limit', '2']); + expect(exitCode).toBe(0); + // Count result entries (lines with score: pattern) + const resultLines = stdout.split('\n').filter((line) => line.includes('score:')); + expect(resultLines.length).toBeLessThanOrEqual(2); + }); + + it('should respect --category filter', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'search', 'configure', '--category', 'config']); + expect(exitCode).toBe(0); + // Results should only be from config category + if (stdout.includes('result(s)')) { + expect(stdout).toContain('[config]'); + expect(stdout).not.toContain('[setup]'); + } + }); + + it('should show no-results message for nonsense query', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'search', 'xyznonexistent123']); + expect(exitCode).toBe(0); + expect(stdout).toContain('No skills found'); + }); + }); + + // ─── skills install ───────────────────────────────────────────────────────── + + describe('skills install', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'frontmcp-skills-e2e-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should install a skill to a custom directory', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'install', 'frontmcp-setup', '--dir', tmpDir]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Installed'); + expect(stdout).toContain('frontmcp-setup'); + + // Verify SKILL.md was copied + const skillMd = path.join(tmpDir, 'frontmcp-setup', 'SKILL.md'); + expect(fs.existsSync(skillMd)).toBe(true); + + // Verify content is non-empty + const content = fs.readFileSync(skillMd, 'utf-8'); + expect(content.length).toBeGreaterThan(100); + expect(content).toContain('frontmcp-setup'); + }); + + it('should install a skill that has resources', () => { + const { stdout, exitCode } = runFrontmcpCli(['skills', 'install', 'frontmcp-deployment', '--dir', tmpDir]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Installed'); + + // frontmcp-deployment has hasResources: true — verify references/ was copied + const skillDir = path.join(tmpDir, 'frontmcp-deployment'); + expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true); + const refDir = path.join(skillDir, 'references'); + expect(fs.existsSync(refDir)).toBe(true); + }); + + it('should error on unknown skill name', () => { + const { stdout, stderr, exitCode } = runFrontmcpCli([ + 'skills', + 'install', + 'nonexistent-skill-xyz', + '--dir', + tmpDir, + ]); + expect(exitCode).not.toBe(0); + const output = stdout + stderr; + expect(output.toLowerCase()).toContain('not found'); + }); + + it('should install to directory specified by --dir', () => { + const baseDir = path.join(tmpDir, 'project'); + fs.mkdirSync(baseDir, { recursive: true }); + + const { exitCode } = runFrontmcpCli([ + 'skills', + 'install', + 'frontmcp-setup', + '--provider', + 'claude', + '--dir', + baseDir, + ]); + expect(exitCode).toBe(0); + + // Should exist under the base dir + const skillMd = path.join(baseDir, 'frontmcp-setup', 'SKILL.md'); + expect(fs.existsSync(skillMd)).toBe(true); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts b/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts index e41b0383..ead1076b 100644 --- a/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts +++ b/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts @@ -78,6 +78,35 @@ export function runCli(args: string[], extraEnv?: Record): CliRe } } +// ─── FrontMCP CLI Helpers ───────────────────────────────────────────────────── + +const ROOT_DIR = path.resolve(__dirname, '../../../../..'); +const FRONTMCP_BIN = path.join(ROOT_DIR, 'libs', 'cli', 'dist', 'src', 'core', 'cli.js'); + +/** + * Run the frontmcp CLI binary directly (not a bundled demo app). + * Used for testing CLI-level commands like `skills search`, `skills list`, etc. + * Resolves @frontmcp/skills via monorepo workspace symlinks. + */ +export function runFrontmcpCli(args: string[], extraEnv?: Record): CliResult { + try { + const stdout = execFileSync('node', [FRONTMCP_BIN, ...args], { + cwd: ROOT_DIR, + timeout: 30000, + encoding: 'utf-8', + env: { ...process.env, NODE_ENV: 'test', ...extraEnv }, + }); + return { stdout: stdout.toString(), stderr: '', exitCode: 0 }; + } catch (err: unknown) { + const error = err as { stdout?: string | Buffer; stderr?: string | Buffer; status?: number }; + return { + stdout: (error.stdout || '').toString(), + stderr: (error.stderr || '').toString(), + exitCode: error.status ?? 1, + }; + } +} + /** * Spawn a long-running server process (CLI serve or server bundle). * Returns the ChildProcess for manual lifecycle management. diff --git a/libs/cli/package.json b/libs/cli/package.json index ea0c377d..dead2293 100644 --- a/libs/cli/package.json +++ b/libs/cli/package.json @@ -39,6 +39,7 @@ "@frontmcp/utils": "1.0.0-beta.8", "commander": "^13.0.0", "tslib": "^2.3.0", + "vectoriadb": "^2.1.3", "@rspack/core": "^1.7.6", "esbuild": "^0.27.3" }, diff --git a/libs/cli/src/commands/scaffold/create.ts b/libs/cli/src/commands/scaffold/create.ts index 534b9e97..f735a1ab 100644 --- a/libs/cli/src/commands/scaffold/create.ts +++ b/libs/cli/src/commands/scaffold/create.ts @@ -1,10 +1,38 @@ import * as path from 'path'; import { createRequire } from 'module'; import { c } from '../../core/colors'; -import { ensureDir, fileExists, isDirEmpty, writeFile, writeJSON, readJSON, runCmd, stat } from '@frontmcp/utils'; +import { + ensureDir, + fileExists, + isDirEmpty, + writeFile, + writeJSON, + readFile, + readJSON, + runCmd, + stat, + cp, + copyFile, +} from '@frontmcp/utils'; import { runInit } from '../../core/tsconfig'; import { getSelfVersion } from '../../core/version'; import { clack } from '../../shared/prompts'; +// Inline skill manifest types to avoid build dependency on @frontmcp/skills source +interface SkillCatalogEntry { + name: string; + category: string; + description: string; + path: string; + targets: string[]; + hasResources: boolean; + tags: string[]; + bundle?: string[]; + install: { destinations: string[]; mergeStrategy: string; dependencies?: string[] }; +} +interface SkillManifest { + version: number; + skills: SkillCatalogEntry[]; +} // ============================================================================= // Types @@ -13,6 +41,7 @@ import { clack } from '../../shared/prompts'; export type DeploymentTarget = 'node' | 'vercel' | 'lambda' | 'cloudflare'; export type RedisSetup = 'docker' | 'existing' | 'none'; export type PackageManager = 'npm' | 'yarn' | 'pnpm'; +export type SkillsBundle = 'recommended' | 'minimal' | 'full' | 'none'; export interface CreateOptions { projectName: string; @@ -21,6 +50,7 @@ export interface CreateOptions { enableGitHubActions: boolean; packageManager: PackageManager; nxScaffolded?: boolean; + skillsBundle?: SkillsBundle; } export interface CreateFlags { @@ -30,6 +60,7 @@ export interface CreateFlags { cicd?: boolean; pm?: PackageManager; nx?: boolean; + skills?: SkillsBundle; } interface PmConfig { @@ -1423,6 +1454,93 @@ async function collectOptions(projectArg?: string, flags?: CreateFlags): Promise }; } +async function scaffoldSkills(targetDir: string, options: CreateOptions): Promise { + const bundle = options.skillsBundle ?? 'recommended'; + if (bundle === 'none') return; + + let manifest: SkillManifest; + try { + const catalogDir = path.resolve(__dirname, '..', '..', '..', '..', 'skills', 'catalog'); + const manifestPath = path.join(catalogDir, 'skills-manifest.json'); + + // Try bundled catalog first, then fallback to @frontmcp/skills package + let manifestContent: string; + if (await fileExists(manifestPath)) { + manifestContent = await readFile(manifestPath); + } else { + try { + const require_ = createRequire(__filename); + const pkgManifest = require_.resolve('@frontmcp/skills/catalog/skills-manifest.json'); + manifestContent = await readFile(pkgManifest); + } catch { + // Skills catalog not available — skip silently + return; + } + } + manifest = JSON.parse(manifestContent) as SkillManifest; + } catch { + return; + } + + const target = options.deploymentTarget; + + // Filter skills by target and bundle + const matchingSkills = manifest.skills.filter((s) => { + const targetMatch = s.targets.includes('all') || s.targets.includes(target); + const bundleMatch = s.bundle?.includes(bundle); + return targetMatch && bundleMatch; + }); + + if (matchingSkills.length === 0) return; + + const skillsDir = path.join(targetDir, 'skills'); + + for (const skill of matchingSkills) { + const skillTargetDir = path.join(skillsDir, skill.name); + await ensureDir(skillTargetDir); + + // Resolve source skill directory + let sourceDir: string | undefined; + const bundledSource = path.resolve(__dirname, '..', '..', '..', '..', 'skills', 'catalog', skill.path); + if (await fileExists(path.join(bundledSource, 'SKILL.md'))) { + sourceDir = bundledSource; + } else { + try { + const require_ = createRequire(__filename); + const pkgCatalog = path.dirname(require_.resolve('@frontmcp/skills/catalog/skills-manifest.json')); + const pkgSource = path.join(pkgCatalog, skill.path); + if (await fileExists(path.join(pkgSource, 'SKILL.md'))) { + sourceDir = pkgSource; + } + } catch { + // Package not available + } + } + + if (!sourceDir) continue; + + // Copy SKILL.md (binary-safe) + const skillMdSrc = path.join(sourceDir, 'SKILL.md'); + if (await fileExists(skillMdSrc)) { + await copyFile(skillMdSrc, path.join(skillTargetDir, 'SKILL.md')); + } + + // Copy resource directories if present (binary-safe recursive copy) + if (skill.hasResources) { + for (const resDir of ['scripts', 'references', 'assets']) { + const srcRes = path.join(sourceDir, resDir); + if (await fileExists(srcRes)) { + await cp(srcRes, path.join(skillTargetDir, resDir), { recursive: true }); + } + } + } + + console.log(c('green', `✓ added skill: ${skill.name}`)); + } + + console.log(c('gray', ` ${matchingSkills.length} skills added (bundle: ${bundle})`)); +} + async function scaffoldDeploymentFiles(targetDir: string, options: CreateOptions): Promise { const { deploymentTarget, redisSetup, projectName } = options; @@ -1570,6 +1688,9 @@ async function scaffoldProject(options: CreateOptions): Promise { await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'jest.e2e.config.ts'), TEMPLATE_JEST_E2E_CONFIG); await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'tsconfig.e2e.json'), TEMPLATE_TSCONFIG_E2E); + // Skills scaffolding + await scaffoldSkills(targetDir, options); + // Git configuration await scaffoldFileIfMissing(targetDir, path.join(targetDir, '.gitignore'), TEMPLATE_GITIGNORE); @@ -1779,6 +1900,7 @@ export async function runCreate(projectArg?: string, flags?: CreateFlags): Promi if (flags?.redis) options.redisSetup = flags.redis; if (flags?.cicd !== undefined) options.enableGitHubActions = flags.cicd; if (flags?.pm) options.packageManager = flags.pm; + if (flags?.skills) options.skillsBundle = flags.skills; if (projectArg) options.projectName = projectArg; if (!options.projectName) { diff --git a/libs/cli/src/commands/scaffold/register.ts b/libs/cli/src/commands/scaffold/register.ts index 24517abb..a40c36c7 100644 --- a/libs/cli/src/commands/scaffold/register.ts +++ b/libs/cli/src/commands/scaffold/register.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import type { DeploymentTarget, RedisSetup, PackageManager } from './create.js'; +import type { DeploymentTarget, RedisSetup, PackageManager, SkillsBundle } from './create.js'; export function registerScaffoldCommands(program: Command): void { program @@ -13,10 +13,19 @@ export function registerScaffoldCommands(program: Command): void { .option('--cicd', 'Enable GitHub Actions CI/CD') .option('--no-cicd', 'Disable GitHub Actions CI/CD') .option('--nx', 'Scaffold an Nx monorepo instead of standalone project') + .option('--skills ', 'Skills bundle: recommended, minimal, full, none (default: recommended)') .action( async ( name: string | undefined, - options: { yes?: boolean; target?: string; redis?: string; pm?: string; cicd?: boolean; nx?: boolean }, + options: { + yes?: boolean; + target?: string; + redis?: string; + pm?: string; + cicd?: boolean; + nx?: boolean; + skills?: string; + }, ) => { const { runCreate } = await import('./create.js'); await runCreate(name, { @@ -26,6 +35,7 @@ export function registerScaffoldCommands(program: Command): void { cicd: options.cicd, pm: options.pm as PackageManager | undefined, nx: options.nx, + skills: options.skills as SkillsBundle | undefined, }); }, ); diff --git a/libs/cli/src/commands/skills/catalog.ts b/libs/cli/src/commands/skills/catalog.ts new file mode 100644 index 00000000..8dfd92c7 --- /dev/null +++ b/libs/cli/src/commands/skills/catalog.ts @@ -0,0 +1,318 @@ +/** + * Catalog loader and TF-IDF search engine for skills. + * + * Uses vectoriadb's TFIDFVectoria for proper TF-IDF similarity search with + * weighted document fields: description 3x, tags 2x, name 1x, category 1x. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { TFIDFVectoria } from 'vectoriadb'; + +interface SkillEntry { + name: string; + category: string; + description: string; + path: string; + targets: string[]; + hasResources: boolean; + tags: string[]; + bundle?: string[]; +} + +interface SkillManifest { + version: number; + skills: SkillEntry[]; +} + +export interface SearchResult { + skill: SkillEntry; + score: number; +} + +interface SkillDocMetadata { + id: string; + skill: SkillEntry; +} + +const STOP_WORDS = new Set([ + // Articles & determiners + 'the', + 'a', + 'an', + 'this', + 'that', + 'these', + 'those', + 'each', + 'every', + 'some', + 'any', + 'no', + // Conjunctions & prepositions + 'and', + 'or', + 'but', + 'nor', + 'for', + 'yet', + 'so', + 'with', + 'from', + 'into', + 'onto', + 'about', + 'by', + 'at', + 'in', + 'on', + 'to', + 'of', + 'as', + 'if', + 'than', + 'then', + 'between', + 'through', + 'after', + 'before', + 'during', + 'without', + 'within', + 'along', + 'across', + 'against', + 'under', + 'over', + 'above', + 'below', + // Pronouns + 'your', + 'you', + 'it', + 'its', + 'we', + 'our', + 'they', + 'them', + 'their', + 'he', + 'she', + 'his', + 'her', + 'who', + 'which', + 'what', + 'where', + 'when', + 'how', + 'why', + // Verbs (auxiliary / common) + 'is', + 'am', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'having', + 'do', + 'does', + 'did', + 'will', + 'would', + 'shall', + 'should', + 'may', + 'might', + 'must', + 'can', + 'could', + 'need', + 'use', + 'using', + 'used', + // Adverbs & modifiers + 'not', + 'very', + 'also', + 'just', + 'only', + 'more', + 'most', + 'less', + 'well', + 'even', + 'still', + 'already', + 'always', + 'never', + 'often', + 'too', + 'here', + 'there', + 'now', + // Common filler + 'all', + 'both', + 'other', + 'another', + 'such', + 'like', + 'get', + 'set', + 'new', + 'make', + 'see', + 'way', + 'etc', + 'via', +]); + +let cachedManifest: SkillManifest | undefined; +let cachedIndex: TFIDFVectoria | undefined; + +/** + * Load the catalog manifest via the @frontmcp/skills package. + * Works in both monorepo (workspace symlink) and installed (npx/npm) environments. + */ +export function loadCatalog(): SkillManifest { + if (cachedManifest) return cachedManifest; + + const manifestPath = resolveManifestPath(); + cachedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as SkillManifest; + return cachedManifest; +} + +/** + * Resolve the path to skills-manifest.json from the @frontmcp/skills package. + */ +function resolveManifestPath(): string { + // Primary: resolve directly from the @frontmcp/skills package + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require.resolve('@frontmcp/skills/catalog/skills-manifest.json'); + } catch { + // Not resolvable via subpath — try via package root + } + + // Fallback: find the package root and navigate to catalog/ + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkgJsonPath = require.resolve('@frontmcp/skills/package.json'); + const pkgRoot = path.dirname(pkgJsonPath); + const manifestPath = path.join(pkgRoot, 'catalog', 'skills-manifest.json'); + if (fs.existsSync(manifestPath)) return manifestPath; + } catch { + // Package not found at all + } + + // Monorepo dev fallback: walk up from __dirname to find libs/skills/catalog/ + let dir = __dirname; + for (let i = 0; i < 8; i++) { + const candidate = path.join(dir, 'libs', 'skills', 'catalog', 'skills-manifest.json'); + if (fs.existsSync(candidate)) return candidate; + dir = path.dirname(dir); + } + + throw new Error( + 'Skills catalog not found. Make sure @frontmcp/skills is installed or you are in the FrontMCP monorepo.', + ); +} + +/** + * Resolve the catalog directory path. + */ +export function getCatalogDir(): string { + return path.dirname(resolveManifestPath()); +} + +/** + * Build and cache the TF-IDF search index from the catalog manifest. + */ +function getSearchIndex(): TFIDFVectoria { + if (cachedIndex) return cachedIndex; + + const manifest = loadCatalog(); + cachedIndex = new TFIDFVectoria({ + defaultTopK: 10, + defaultSimilarityThreshold: 0.0, + }); + + const documents = manifest.skills.map((skill) => ({ + id: skill.name, + text: buildSearchableText(skill), + metadata: { id: skill.name, skill }, + })); + + cachedIndex.addDocuments(documents); + cachedIndex.reindex(); + + return cachedIndex; +} + +/** + * Build weighted searchable text for TF-IDF indexing. + * Follows the same weighting pattern as the SDK's MemorySkillProvider. + */ +function buildSearchableText(skill: SkillEntry): string { + const parts: string[] = []; + + // Name tokens (1x) + const nameParts = skill.name.split(/[-_.\s]/).filter(Boolean); + parts.push(...nameParts); + + // Description (3x weight — repeat for TF-IDF term frequency boost) + if (skill.description) { + parts.push(skill.description, skill.description, skill.description); + + // Extract key terms from description (additional boost for meaningful words) + const keyTerms = skill.description + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length >= 4 && !STOP_WORDS.has(word)); + parts.push(...keyTerms); + } + + // Tags (2x weight) + for (const tag of skill.tags) { + parts.push(tag, tag); + } + + // Category (1x weight) + parts.push(skill.category); + + return parts.join(' '); +} + +/** + * Search skills using TF-IDF similarity via vectoriadb. + */ +export function searchCatalog( + query: string, + options?: { tag?: string; category?: string; limit?: number }, +): SearchResult[] { + const index = getSearchIndex(); + const topK = options?.limit ?? 10; + + const filter = (metadata: SkillDocMetadata): boolean => { + if (options?.tag && !metadata.skill.tags.includes(options.tag)) return false; + if (options?.category && metadata.skill.category !== options.category) return false; + return true; + }; + + const results = index.search(query, { + topK, + threshold: 0.01, + filter, + }); + + return results.map((r) => ({ + skill: r.metadata.skill, + score: r.score, + })); +} diff --git a/libs/cli/src/commands/skills/install.ts b/libs/cli/src/commands/skills/install.ts new file mode 100644 index 00000000..5805d32b --- /dev/null +++ b/libs/cli/src/commands/skills/install.ts @@ -0,0 +1,50 @@ +import * as path from 'path'; +import { c } from '../../core/colors'; +import { ensureDir, fileExists, cp } from '@frontmcp/utils'; +import { loadCatalog, getCatalogDir } from './catalog'; + +const PROVIDER_DIRS: Record = { + claude: '.claude/skills', + codex: '.codex/skills', +}; + +export async function installSkill( + name: string, + options: { provider?: 'claude' | 'codex'; dir?: string }, +): Promise { + const manifest = loadCatalog(); + const entry = manifest.skills.find((s) => s.name === name); + + if (!entry) { + console.error(c('red', `Skill "${name}" not found in catalog.`)); + console.log(c('gray', "Use 'frontmcp skills list' to see available skills.")); + process.exit(1); + } + + const provider = options.provider ?? 'claude'; + const targetBase = options.dir ?? path.resolve(process.cwd(), PROVIDER_DIRS[provider] ?? PROVIDER_DIRS['claude']); + const targetDir = path.join(targetBase, name); + + const catalogDir = getCatalogDir(); + const sourceDir = path.join(catalogDir, entry.path); + + if (!(await fileExists(path.join(sourceDir, 'SKILL.md')))) { + console.error(c('red', `Source SKILL.md not found at ${sourceDir}`)); + process.exit(1); + } + + // Copy skill directory (binary-safe recursive copy) + await ensureDir(targetDir); + await cp(sourceDir, targetDir, { recursive: true }); + + console.log( + `${c('green', '✓')} Installed skill ${c('bold', name)} to ${c('cyan', path.relative(process.cwd(), targetDir))}`, + ); + + if (entry.hasResources) { + console.log(c('gray', ' Includes: references/ directory')); + } + + console.log(c('gray', ` Provider: ${provider}`)); + console.log(c('gray', ` Path: ${targetDir}`)); +} diff --git a/libs/cli/src/commands/skills/list.ts b/libs/cli/src/commands/skills/list.ts new file mode 100644 index 00000000..0a398ef1 --- /dev/null +++ b/libs/cli/src/commands/skills/list.ts @@ -0,0 +1,46 @@ +import { c } from '../../core/colors'; +import { loadCatalog } from './catalog'; + +export async function listSkills(options: { category?: string; tag?: string; bundle?: string }): Promise { + const manifest = loadCatalog(); + let skills = manifest.skills; + + if (options.category) { + skills = skills.filter((s) => s.category === options.category); + } + if (options.tag) { + skills = skills.filter((s) => s.tags.includes(options.tag!)); + } + if (options.bundle) { + skills = skills.filter((s) => s.bundle?.includes(options.bundle!)); + } + + if (skills.length === 0) { + console.log(c('yellow', 'No skills found matching filters.')); + return; + } + + // Group by category + const grouped = new Map(); + for (const skill of skills) { + const cat = skill.category; + if (!grouped.has(cat)) grouped.set(cat, []); + grouped.get(cat)!.push(skill); + } + + console.log(c('bold', `\n FrontMCP Skills Catalog (${skills.length} skills)\n`)); + + for (const [category, catSkills] of grouped) { + console.log(` ${c('cyan', category.toUpperCase())} (${catSkills.length})`); + for (const skill of catSkills) { + const desc = skill.description.split('. Use when')[0]; + const res = skill.hasResources ? ' 📁' : ''; + console.log(` ${c('green', skill.name)}${res} ${c('gray', desc)}`); + } + console.log(''); + } + + console.log(c('gray', ' 📁 = has references/scripts/assets')); + console.log(c('gray', " Use 'frontmcp skills search ' for semantic search")); + console.log(c('gray', " Use 'frontmcp skills install --provider claude' to install\n")); +} diff --git a/libs/cli/src/commands/skills/register.ts b/libs/cli/src/commands/skills/register.ts new file mode 100644 index 00000000..9ae52fe1 --- /dev/null +++ b/libs/cli/src/commands/skills/register.ts @@ -0,0 +1,62 @@ +import { Command } from 'commander'; + +export function registerSkillsCommands(program: Command): void { + const skills = program.command('skills').description('Search, list, and install skills from the FrontMCP catalog'); + + skills + .command('search') + .description('Search the skills catalog using semantic text matching') + .argument('', 'Search text (matches descriptions, tags, and names)') + .option('-n, --limit ', 'Maximum results to return', '10') + .option('-t, --tag ', 'Filter by tag') + .option('-c, --category ', 'Filter by category') + .action(async (query: string, options: { limit?: string; tag?: string; category?: string }) => { + const { searchSkills } = await import('./search.js'); + await searchSkills(query, { + limit: Math.max(1, Number(options.limit) || 10), + tag: options.tag, + category: options.category, + }); + }); + + skills + .command('list') + .description('List all available skills in the catalog') + .option('-c, --category ', 'Filter by category') + .option('-t, --tag ', 'Filter by tag') + .option('-b, --bundle ', 'Filter by bundle (recommended, minimal, full)') + .action(async (options: { category?: string; tag?: string; bundle?: string }) => { + const { listSkills } = await import('./list.js'); + await listSkills(options); + }); + + skills + .command('install') + .description('Install a skill to a provider directory (.claude/skills or .codex/skills)') + .argument('', 'Skill name to install') + .option('-p, --provider ', 'Target provider: claude, codex (default: claude)', 'claude') + .option('-d, --dir ', 'Custom install directory (overrides provider default)') + .action(async (name: string, options: { provider?: string; dir?: string }) => { + const validProviders = ['claude', 'codex'] as const; + type Provider = (typeof validProviders)[number]; + const raw = options.provider; + if (raw && !validProviders.includes(raw as Provider)) { + console.error(`Invalid provider "${raw}". Valid providers: ${validProviders.join(', ')}`); + process.exit(1); + } + const { installSkill } = await import('./install.js'); + await installSkill(name, { + provider: raw as Provider | undefined, + dir: options.dir, + }); + }); + + skills + .command('show') + .description('Show full details of a skill including instructions') + .argument('', 'Skill name') + .action(async (name: string) => { + const { showSkill } = await import('./show.js'); + await showSkill(name); + }); +} diff --git a/libs/cli/src/commands/skills/search.ts b/libs/cli/src/commands/skills/search.ts new file mode 100644 index 00000000..1ba4194e --- /dev/null +++ b/libs/cli/src/commands/skills/search.ts @@ -0,0 +1,28 @@ +import { c } from '../../core/colors'; +import { searchCatalog } from './catalog'; + +export async function searchSkills( + query: string, + options: { limit: number; tag?: string; category?: string }, +): Promise { + const results = searchCatalog(query, options); + + if (results.length === 0) { + console.log(c('yellow', `No skills found matching "${query}".`)); + console.log(c('gray', 'Try: frontmcp skills list --category setup')); + return; + } + + console.log(c('bold', `\n Skills matching "${query}":\n`)); + + for (const { skill, score } of results) { + const tags = skill.tags.slice(0, 3).join(', '); + console.log(` ${c('green', skill.name)} ${c('gray', `[${skill.category}]`)} ${c('gray', `score:${score}`)}`); + console.log(` ${skill.description.split('. Use when')[0]}`); + console.log(` ${c('gray', `tags: ${tags}`)}`); + console.log(''); + } + + console.log(c('gray', ` ${results.length} result(s). Use 'frontmcp skills show ' for full details.`)); + console.log(c('gray', ` Install: 'frontmcp skills install --provider claude'\n`)); +} diff --git a/libs/cli/src/commands/skills/show.ts b/libs/cli/src/commands/skills/show.ts new file mode 100644 index 00000000..360e4dac --- /dev/null +++ b/libs/cli/src/commands/skills/show.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; +import { c } from '../../core/colors'; +import { fileExists, readFile } from '@frontmcp/utils'; +import { loadCatalog, getCatalogDir } from './catalog'; + +export async function showSkill(name: string): Promise { + const manifest = loadCatalog(); + const entry = manifest.skills.find((s) => s.name === name); + + if (!entry) { + console.error(c('red', `Skill "${name}" not found in catalog.`)); + console.log(c('gray', "Use 'frontmcp skills list' to see available skills.")); + process.exit(1); + } + + const catalogDir = getCatalogDir(); + const skillDir = path.join(catalogDir, entry.path); + const skillMd = path.join(skillDir, 'SKILL.md'); + + if (!(await fileExists(skillMd))) { + console.error(c('red', `SKILL.md not found at ${skillMd}`)); + process.exit(1); + } + + const content = await readFile(skillMd); + + console.log(c('bold', `\n ${entry.name}`)); + console.log(c('gray', ` Category: ${entry.category}`)); + console.log(c('gray', ` Tags: ${entry.tags.join(', ')}`)); + console.log(c('gray', ` Targets: ${entry.targets.join(', ')}`)); + console.log(c('gray', ` Bundle: ${entry.bundle?.join(', ') ?? 'none'}`)); + console.log(c('gray', ` Has resources: ${entry.hasResources}`)); + console.log(''); + console.log(c('gray', ' ─────────────────────────────────────')); + console.log(''); + + // Print body (skip frontmatter) + const bodyStart = content.indexOf('---', 3); + if (bodyStart !== -1) { + const body = content.substring(bodyStart + 3).trim(); + console.log(body); + } else { + console.log(content); + } + + console.log(''); + console.log(c('gray', ` Install: frontmcp skills install ${name} --provider claude`)); +} diff --git a/libs/cli/src/core/__tests__/program.spec.ts b/libs/cli/src/core/__tests__/program.spec.ts index 308e3036..2a90871e 100644 --- a/libs/cli/src/core/__tests__/program.spec.ts +++ b/libs/cli/src/core/__tests__/program.spec.ts @@ -11,7 +11,7 @@ describe('createProgram', () => { expect(program.version()).toMatch(/^\d+\.\d+\.\d+/); }); - it('should register all 18 commands', () => { + it('should register all 19 commands', () => { const program = createProgram(); const names = program.commands.map((c) => c.name()).sort(); expect(names).toEqual([ @@ -27,6 +27,7 @@ describe('createProgram', () => { 'logs', 'restart', 'service', + 'skills', 'socket', 'start', 'status', diff --git a/libs/cli/src/core/program.ts b/libs/cli/src/core/program.ts index 235ec8c5..166a69df 100644 --- a/libs/cli/src/core/program.ts +++ b/libs/cli/src/core/program.ts @@ -5,6 +5,7 @@ import { registerBuildCommands } from '../commands/build/register'; import { registerScaffoldCommands } from '../commands/scaffold/register'; import { registerPmCommands } from '../commands/pm/register'; import { registerPackageCommands } from '../commands/package/register'; +import { registerSkillsCommands } from '../commands/skills/register'; import { customizeHelp } from './help'; export function createProgram(): Command { @@ -17,6 +18,7 @@ export function createProgram(): Command { registerScaffoldCommands(program); registerPmCommands(program); registerPackageCommands(program); + registerSkillsCommands(program); customizeHelp(program); return program; diff --git a/libs/nx-plugin/generators.json b/libs/nx-plugin/generators.json index 666fe01f..0e764a19 100644 --- a/libs/nx-plugin/generators.json +++ b/libs/nx-plugin/generators.json @@ -40,6 +40,11 @@ "schema": "./src/generators/skill/schema.json", "description": "Generate a @Skill class" }, + "skill-dir": { + "factory": "./src/generators/skill-dir/skill-dir", + "schema": "./src/generators/skill-dir/schema.json", + "description": "Generate a SKILL.md-based skill directory" + }, "agent": { "factory": "./src/generators/agent/agent", "schema": "./src/generators/agent/schema.json", diff --git a/libs/nx-plugin/package.json b/libs/nx-plugin/package.json index 164991ea..69bfa650 100644 --- a/libs/nx-plugin/package.json +++ b/libs/nx-plugin/package.json @@ -33,6 +33,7 @@ "node": ">=22.0.0" }, "dependencies": { + "@frontmcp/skills": "1.0.0-beta.8", "@nx/devkit": "22.3.3", "tslib": "^2.3.0" }, diff --git a/libs/nx-plugin/src/generators/server/schema.json b/libs/nx-plugin/src/generators/server/schema.json index f2adb1f1..36f38ad8 100644 --- a/libs/nx-plugin/src/generators/server/schema.json +++ b/libs/nx-plugin/src/generators/server/schema.json @@ -51,6 +51,12 @@ "description": "Comma-separated tags", "x-priority": "internal" }, + "skills": { + "type": "string", + "description": "Skills bundle to include: recommended, minimal, full, none", + "enum": ["recommended", "minimal", "full", "none"], + "default": "recommended" + }, "skipFormat": { "type": "boolean", "description": "Skip formatting files", diff --git a/libs/nx-plugin/src/generators/server/schema.ts b/libs/nx-plugin/src/generators/server/schema.ts index 5222b9f9..923eb553 100644 --- a/libs/nx-plugin/src/generators/server/schema.ts +++ b/libs/nx-plugin/src/generators/server/schema.ts @@ -4,6 +4,7 @@ export interface ServerGeneratorSchema { deploymentTarget?: 'node' | 'vercel' | 'lambda' | 'cloudflare'; apps: string; redis?: 'docker' | 'existing' | 'none'; + skills?: 'recommended' | 'minimal' | 'full' | 'none'; tags?: string; skipFormat?: boolean; } diff --git a/libs/nx-plugin/src/generators/server/server.ts b/libs/nx-plugin/src/generators/server/server.ts index a10ebe65..519f991b 100644 --- a/libs/nx-plugin/src/generators/server/server.ts +++ b/libs/nx-plugin/src/generators/server/server.ts @@ -1,4 +1,5 @@ import { type Tree, formatFiles, generateFiles, names as nxNames, type GeneratorCallback } from '@nx/devkit'; +import * as fs from 'fs'; import { join } from 'path'; import type { ServerGeneratorSchema } from './schema.js'; import { normalizeOptions } from './lib/index.js'; @@ -24,9 +25,69 @@ async function serverGeneratorInternal(tree: Tree, schema: ServerGeneratorSchema const targetDir = join(__dirname, 'files', options.deploymentTarget); generateFiles(tree, targetDir, options.projectRoot, templateVars); + // Copy skills from catalog + const bundle = schema.skills ?? 'recommended'; + if (bundle !== 'none') { + scaffoldCatalogSkills(tree, options.projectRoot, options.deploymentTarget, bundle); + } + if (!options.skipFormat) { await formatFiles(tree); } } +function scaffoldCatalogSkills(tree: Tree, projectRoot: string, target: string, bundle: string): void { + // Load skills catalog via @frontmcp/skills package at runtime + let skills: { + loadManifest: () => { + skills: Array<{ name: string; path: string; targets: string[]; hasResources: boolean; bundle?: string[] }>; + }; + resolveSkillPath: (entry: { path: string }) => string; + getSkillsByTarget: ( + s: Array<{ targets: string[] }>, + t: string, + ) => Array<{ name: string; path: string; targets: string[]; hasResources: boolean; bundle?: string[] }>; + getSkillsByBundle: ( + s: Array<{ bundle?: string[] }>, + b: string, + ) => Array<{ name: string; path: string; targets: string[]; hasResources: boolean; bundle?: string[] }>; + }; + try { + skills = require('@frontmcp/skills'); + } catch { + return; + } + + let manifest; + try { + manifest = skills.loadManifest(); + } catch { + return; + } + + const targetFiltered = skills.getSkillsByTarget(manifest.skills, target); + const matchingSkills = skills.getSkillsByBundle(targetFiltered, bundle); + + for (const skill of matchingSkills) { + const sourceDir = skills.resolveSkillPath(skill); + const destDir = join(projectRoot, 'skills', skill.name); + copyDirToTree(tree, sourceDir, destDir); + } +} + +function copyDirToTree(tree: Tree, sourceDir: string, destDir: string): void { + if (!fs.existsSync(sourceDir)) return; + const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(sourceDir, entry.name); + const destPath = join(destDir, entry.name); + if (entry.isDirectory()) { + copyDirToTree(tree, srcPath, destPath); + } else { + const content = fs.readFileSync(srcPath); + tree.write(destPath, content); + } + } +} + export default serverGenerator; diff --git a/libs/nx-plugin/src/generators/skill-dir/files/__name__/SKILL.md__tmpl__ b/libs/nx-plugin/src/generators/skill-dir/files/__name__/SKILL.md__tmpl__ new file mode 100644 index 00000000..785d4961 --- /dev/null +++ b/libs/nx-plugin/src/generators/skill-dir/files/__name__/SKILL.md__tmpl__ @@ -0,0 +1,14 @@ +--- +name: <%= name %> +description: <%= description %> +tags: [<%= tags %>] +--- +# <%= name %> + +Add your skill instructions here. + +## Steps + +1. First step +2. Second step +3. Third step diff --git a/libs/nx-plugin/src/generators/skill-dir/schema.json b/libs/nx-plugin/src/generators/skill-dir/schema.json new file mode 100644 index 00000000..3d9baea2 --- /dev/null +++ b/libs/nx-plugin/src/generators/skill-dir/schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "FrontMcpSkillDir", + "title": "FrontMCP Skill Directory Generator", + "description": "Generate a SKILL.md-based skill directory", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The skill name (kebab-case)", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like for the skill?", + "x-priority": "important" + }, + "project": { + "type": "string", + "description": "The project to add the skill to", + "x-prompt": "Which project should the skill be added to?", + "x-priority": "important" + }, + "description": { + "type": "string", + "description": "Short description of what the skill does", + "x-prompt": "What does this skill do?", + "x-priority": "important" + }, + "directory": { + "type": "string", + "description": "Custom directory relative to the project root (default: skills)" + }, + "tags": { + "type": "string", + "description": "Comma-separated tags for categorization" + }, + "withReferences": { + "type": "boolean", + "description": "Include a references/ directory", + "default": false + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false, + "x-priority": "internal" + } + }, + "required": ["name", "project"] +} diff --git a/libs/nx-plugin/src/generators/skill-dir/schema.ts b/libs/nx-plugin/src/generators/skill-dir/schema.ts new file mode 100644 index 00000000..9b06d781 --- /dev/null +++ b/libs/nx-plugin/src/generators/skill-dir/schema.ts @@ -0,0 +1,9 @@ +export interface SkillDirGeneratorSchema { + name: string; + project: string; + description?: string; + directory?: string; + tags?: string; + withReferences?: boolean; + skipFormat?: boolean; +} diff --git a/libs/nx-plugin/src/generators/skill-dir/skill-dir.ts b/libs/nx-plugin/src/generators/skill-dir/skill-dir.ts new file mode 100644 index 00000000..99f4cb62 --- /dev/null +++ b/libs/nx-plugin/src/generators/skill-dir/skill-dir.ts @@ -0,0 +1,42 @@ +import { type Tree, formatFiles, generateFiles, readProjectConfiguration, type GeneratorCallback } from '@nx/devkit'; +import { join } from 'path'; +import type { SkillDirGeneratorSchema } from './schema.js'; + +export async function skillDirGenerator( + tree: Tree, + schema: SkillDirGeneratorSchema, +): Promise { + const projectConfig = readProjectConfiguration(tree, schema.project); + const projectRoot = projectConfig.root; + const baseDir = schema.directory ?? 'skills'; + const targetDir = join(projectRoot, baseDir); + + const tags = schema.tags + ? schema.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + .join(', ') + : schema.name; + + const templateVars = { + name: schema.name, + description: schema.description ?? `Skill: ${schema.name}`, + tags, + tmpl: '', + }; + + generateFiles(tree, join(__dirname, 'files'), targetDir, templateVars); + + // Create references/ directory if requested + if (schema.withReferences) { + const refDir = join(targetDir, schema.name, 'references'); + tree.write(join(refDir, '.gitkeep'), ''); + } + + if (!schema.skipFormat) { + await formatFiles(tree); + } +} + +export default skillDirGenerator; diff --git a/libs/sdk/src/skill/__tests__/skill-md-parser.spec.ts b/libs/sdk/src/skill/__tests__/skill-md-parser.spec.ts index 32c215ce..764fe801 100644 --- a/libs/sdk/src/skill/__tests__/skill-md-parser.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-md-parser.spec.ts @@ -211,6 +211,295 @@ Body.`; expect(result.description).toBeUndefined(); expect(result.instructions).toBe('Just body'); }); + + it('should map tools as string array', () => { + const frontmatter = { + name: 'tool-skill', + description: 'Skill with tools', + tools: ['read_file', 'write_file', 'run_test'], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.tools).toEqual(['read_file', 'write_file', 'run_test']); + }); + + it('should map tools as detailed refs with purpose and required', () => { + const frontmatter = { + name: 'ref-skill', + description: 'Skill with tool refs', + tools: [ + 'simple_tool', + { name: 'detailed_tool', purpose: 'Review code', required: true }, + { name: 'optional_tool', purpose: 'Format output' }, + ], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.tools).toEqual([ + 'simple_tool', + { name: 'detailed_tool', purpose: 'Review code', required: true }, + { name: 'optional_tool', purpose: 'Format output' }, + ]); + }); + + it('should skip invalid tool entries', () => { + const frontmatter = { + name: 'bad-tools', + description: 'Skill with invalid tools', + tools: ['valid_tool', 42, null, { noName: true }, { name: 'ok_tool' }], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.tools).toEqual(['valid_tool', { name: 'ok_tool' }]); + }); + + it('should map parameters array', () => { + const frontmatter = { + name: 'param-skill', + description: 'Skill with params', + parameters: [ + { name: 'target', description: 'Deploy target', type: 'string', default: 'node' }, + { name: 'verbose', type: 'boolean', required: true }, + ], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.parameters).toEqual([ + { name: 'target', description: 'Deploy target', type: 'string', default: 'node' }, + { name: 'verbose', type: 'boolean', required: true }, + ]); + }); + + it('should skip parameter entries without name', () => { + const frontmatter = { + name: 'bad-params', + description: 'Skill with invalid params', + parameters: [{ name: 'valid', description: 'Valid param' }, { description: 'Missing name' }, 'not-an-object'], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.parameters).toEqual([{ name: 'valid', description: 'Valid param' }]); + }); + + it('should map examples array', () => { + const frontmatter = { + name: 'example-skill', + description: 'Skill with examples', + examples: [ + { scenario: 'Deploy to production', parameters: { target: 'prod' }, expectedOutcome: 'App deployed' }, + { scenario: 'Run locally' }, + ], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.examples).toEqual([ + { scenario: 'Deploy to production', parameters: { target: 'prod' }, expectedOutcome: 'App deployed' }, + { scenario: 'Run locally' }, + ]); + }); + + it('should handle expected-outcome kebab-case in examples', () => { + const frontmatter = { + name: 'kebab-example', + description: 'Skill with kebab-case example', + examples: [{ scenario: 'Test case', 'expected-outcome': 'Tests pass' }], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.examples).toEqual([{ scenario: 'Test case', expectedOutcome: 'Tests pass' }]); + }); + + it('should skip example entries without scenario', () => { + const frontmatter = { + name: 'bad-examples', + description: 'Skill with invalid examples', + examples: [{ scenario: 'Valid example' }, { expectedOutcome: 'Missing scenario' }, 'not-an-object'], + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.examples).toEqual([{ scenario: 'Valid example' }]); + }); + + it('should map priority number', () => { + const frontmatter = { + name: 'priority-skill', + description: 'High priority', + priority: 10, + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.priority).toBe(10); + }); + + it('should not map non-number priority', () => { + const frontmatter = { + name: 'bad-priority', + description: 'Non-number priority', + priority: 'high', + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.priority).toBeUndefined(); + }); + + it('should map visibility values', () => { + for (const vis of ['mcp', 'http', 'both'] as const) { + const result = skillMdFrontmatterToMetadata( + { name: 'vis-skill', description: 'Test', visibility: vis }, + 'Body', + ); + expect(result.visibility).toBe(vis); + } + }); + + it('should not map invalid visibility', () => { + const result = skillMdFrontmatterToMetadata( + { name: 'bad-vis', description: 'Test', visibility: 'invalid' }, + 'Body', + ); + expect(result.visibility).toBeUndefined(); + }); + + it('should map hideFromDiscovery boolean', () => { + const result = skillMdFrontmatterToMetadata( + { name: 'hidden', description: 'Test', hideFromDiscovery: true }, + 'Body', + ); + expect(result.hideFromDiscovery).toBe(true); + }); + + it('should map hide-from-discovery kebab-case', () => { + const result = skillMdFrontmatterToMetadata( + { name: 'hidden', description: 'Test', 'hide-from-discovery': true }, + 'Body', + ); + expect(result.hideFromDiscovery).toBe(true); + }); + + it('should not map non-boolean hideFromDiscovery', () => { + const result = skillMdFrontmatterToMetadata( + { name: 'bad-hide', description: 'Test', hideFromDiscovery: 'yes' }, + 'Body', + ); + expect(result.hideFromDiscovery).toBeUndefined(); + }); + + it('should map toolValidation values', () => { + for (const tv of ['strict', 'warn', 'ignore'] as const) { + const result = skillMdFrontmatterToMetadata( + { name: 'tv-skill', description: 'Test', toolValidation: tv }, + 'Body', + ); + expect(result.toolValidation).toBe(tv); + } + }); + + it('should map tool-validation kebab-case', () => { + const result = skillMdFrontmatterToMetadata( + { name: 'tv-skill', description: 'Test', 'tool-validation': 'strict' }, + 'Body', + ); + expect(result.toolValidation).toBe('strict'); + }); + + it('should not map invalid toolValidation', () => { + const result = skillMdFrontmatterToMetadata( + { name: 'bad-tv', description: 'Test', toolValidation: 'relaxed' }, + 'Body', + ); + expect(result.toolValidation).toBeUndefined(); + }); + + it('should pass unknown fields through to specMetadata', () => { + const frontmatter = { + name: 'provider-skill', + description: 'Skill with provider fields', + 'user-invocable': true, + 'custom-field': 'custom-value', + 'numeric-meta': 42, + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + expect(result.specMetadata).toEqual({ + 'user-invocable': 'true', + 'custom-field': 'custom-value', + 'numeric-meta': '42', + }); + }); + + it('should merge unknown fields with explicit metadata into specMetadata', () => { + const frontmatter = { + name: 'merge-skill', + description: 'Test merge', + metadata: { author: 'alice' }, + 'user-invocable': true, + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, 'Body'); + + // Explicit metadata maps first, unknown fields add to specMetadata + expect(result.specMetadata!['author']).toBe('alice'); + expect(result.specMetadata!['user-invocable']).toBe('true'); + }); + + it('should map all fields from a comprehensive SKILL.md frontmatter', () => { + const frontmatter = { + name: 'full-skill', + description: 'A comprehensive skill', + license: 'MIT', + compatibility: 'Node.js 18+', + tags: ['setup', 'redis'], + tools: ['configure_redis', { name: 'test_connection', purpose: 'Verify Redis', required: true }], + parameters: [{ name: 'provider', description: 'Redis provider', type: 'string', default: 'docker' }], + examples: [{ scenario: 'Setup Redis for dev', expectedOutcome: 'Redis running on localhost:6379' }], + priority: 5, + visibility: 'both', + hideFromDiscovery: false, + toolValidation: 'strict', + 'allowed-tools': 'Read Edit', + metadata: { version: '1.0' }, + 'user-invocable': true, + }; + + const result = skillMdFrontmatterToMetadata(frontmatter, '# Setup Redis\n\nStep 1...'); + + expect(result.name).toBe('full-skill'); + expect(result.description).toBe('A comprehensive skill'); + expect(result.license).toBe('MIT'); + expect(result.compatibility).toBe('Node.js 18+'); + expect(result.tags).toEqual(['setup', 'redis']); + expect(result.tools).toEqual([ + 'configure_redis', + { name: 'test_connection', purpose: 'Verify Redis', required: true }, + ]); + expect(result.parameters).toEqual([ + { name: 'provider', description: 'Redis provider', type: 'string', default: 'docker' }, + ]); + expect(result.examples).toEqual([ + { scenario: 'Setup Redis for dev', expectedOutcome: 'Redis running on localhost:6379' }, + ]); + expect(result.priority).toBe(5); + expect(result.visibility).toBe('both'); + expect(result.hideFromDiscovery).toBe(false); + expect(result.toolValidation).toBe('strict'); + expect(result.allowedTools).toBe('Read Edit'); + expect(result.specMetadata).toEqual({ + version: '1.0', + 'user-invocable': 'true', + }); + expect(result.instructions).toBe('# Setup Redis\n\nStep 1...'); + }); }); describe('stripFrontmatter', () => { diff --git a/libs/sdk/src/skill/skill-md-parser.ts b/libs/sdk/src/skill/skill-md-parser.ts index 99218377..cce94bd5 100644 --- a/libs/sdk/src/skill/skill-md-parser.ts +++ b/libs/sdk/src/skill/skill-md-parser.ts @@ -11,7 +11,13 @@ import * as yaml from 'js-yaml'; import { readFile } from '@frontmcp/utils'; -import type { SkillMetadata, SkillResources } from '../common/metadata/skill.metadata'; +import type { + SkillMetadata, + SkillResources, + SkillToolRef, + SkillParameter, + SkillExample, +} from '../common/metadata/skill.metadata'; /** * Result of parsing SKILL.md frontmatter. @@ -117,6 +123,107 @@ export function skillMdFrontmatterToMetadata( result.tags = frontmatter['tags'].filter((t): t is string => typeof t === 'string'); } + // Tools — string names or detailed refs from YAML + if (Array.isArray(frontmatter['tools'])) { + result.tools = frontmatter['tools'] + .map((t: unknown): string | SkillToolRef | undefined => { + if (typeof t === 'string') return t; + if ( + typeof t === 'object' && + t !== null && + 'name' in t && + typeof (t as Record)['name'] === 'string' + ) { + const ref: SkillToolRef = { name: (t as Record)['name'] as string }; + if (typeof (t as Record)['purpose'] === 'string') + ref.purpose = (t as Record)['purpose'] as string; + if (typeof (t as Record)['required'] === 'boolean') + ref.required = (t as Record)['required'] as boolean; + return ref; + } + return undefined; + }) + .filter((t): t is string | SkillToolRef => t !== undefined); + } + + // Parameters + if (Array.isArray(frontmatter['parameters'])) { + result.parameters = frontmatter['parameters'] + .filter((p: unknown): p is Record => typeof p === 'object' && p !== null && 'name' in p) + .map((p: Record): SkillParameter => { + const param: SkillParameter = { name: String(p['name']) }; + if (typeof p['description'] === 'string') param.description = p['description']; + if (typeof p['required'] === 'boolean') param.required = p['required']; + if (typeof p['type'] === 'string') param.type = p['type'] as SkillParameter['type']; + if (p['default'] !== undefined) param.default = p['default']; + return param; + }); + } + + // Examples + if (Array.isArray(frontmatter['examples'])) { + result.examples = frontmatter['examples'] + .filter((e: unknown): e is Record => typeof e === 'object' && e !== null && 'scenario' in e) + .map((e: Record): SkillExample => { + const example: SkillExample = { scenario: String(e['scenario']) }; + if (typeof e['parameters'] === 'object' && e['parameters'] !== null) { + example.parameters = e['parameters'] as Record; + } + if (typeof e['expectedOutcome'] === 'string') example.expectedOutcome = e['expectedOutcome']; + if (typeof e['expected-outcome'] === 'string') example.expectedOutcome = e['expected-outcome']; + return example; + }); + } + + // Priority + if (typeof frontmatter['priority'] === 'number') { + result.priority = frontmatter['priority']; + } + + // Visibility + const vis = frontmatter['visibility']; + if (vis === 'mcp' || vis === 'http' || vis === 'both') { + result.visibility = vis; + } + + // hideFromDiscovery (supports kebab-case from YAML) + const hide = frontmatter['hideFromDiscovery'] ?? frontmatter['hide-from-discovery']; + if (typeof hide === 'boolean') { + result.hideFromDiscovery = hide; + } + + // toolValidation (supports kebab-case from YAML) + const tv = frontmatter['toolValidation'] ?? frontmatter['tool-validation']; + if (tv === 'strict' || tv === 'warn' || tv === 'ignore') { + result.toolValidation = tv; + } + + // Pass unknown fields through to specMetadata (preserves provider-specific fields like user-invocable) + const knownKeys = new Set([ + 'name', + 'description', + 'license', + 'compatibility', + 'metadata', + 'allowed-tools', + 'tags', + 'tools', + 'parameters', + 'examples', + 'priority', + 'visibility', + 'hideFromDiscovery', + 'hide-from-discovery', + 'toolValidation', + 'tool-validation', + ]); + for (const [key, val] of Object.entries(frontmatter)) { + if (!knownKeys.has(key) && val !== undefined) { + if (!result.specMetadata) result.specMetadata = {}; + result.specMetadata[key] = typeof val === 'string' ? val : JSON.stringify(val); + } + } + // Body becomes instructions if (body.length > 0) { result.instructions = body; diff --git a/libs/skills/README.md b/libs/skills/README.md new file mode 100644 index 00000000..2e17e12e --- /dev/null +++ b/libs/skills/README.md @@ -0,0 +1,127 @@ +# @frontmcp/skills + +Curated skills catalog for FrontMCP projects. Skills are SKILL.md-based instructional packages that teach AI agents how to perform multi-step tasks with FrontMCP. + +## Structure + +The catalog uses a **router skill model** — 6 domain-scoped router skills, each containing a SKILL.md with a routing table and a `references/` directory with detailed reference files. + +```text +catalog/ +├── skills-manifest.json # Machine-readable index of all skills +├── frontmcp-setup/ # Project setup, scaffolding, Nx, storage backends +├── frontmcp-development/ # Tools, resources, prompts, agents, providers, jobs, workflows, skills +├── frontmcp-deployment/ # Deploy to Node, Vercel, Lambda, Cloudflare; build for CLI, browser, SDK +├── frontmcp-testing/ # Testing with Jest and @frontmcp/testing +├── frontmcp-config/ # Transport, HTTP, throttle, elicitation, auth, sessions, storage +└── frontmcp-guides/ # End-to-end examples and best practices +``` + +Each router skill directory follows this format: + +``` +frontmcp-development/ +├── SKILL.md # Required: frontmatter + routing table + instructions +└── references/ # Reference files with detailed per-topic guides + ├── create-tool.md + ├── create-resource.md + ├── create-agent.md + └── ... +``` + +## SKILL.md Frontmatter + +```yaml +--- +name: my-skill # Required: kebab-case, max 64 chars +description: What the skill does # Required: short description +tags: [setup, redis] # Optional: categorization tags +tools: # Optional: tool references + - tool_name + - name: detailed_tool + purpose: Why this tool is used + required: true +parameters: # Optional: input parameters + - name: param_name + description: What it controls + type: string + default: value +examples: # Optional: usage examples + - scenario: When to use this + expected-outcome: What happens +priority: 5 # Optional: search ranking weight +visibility: both # Optional: mcp | http | both +compatibility: Node.js 18+ # Optional: environment requirements +license: MIT # Optional: license +allowed-tools: Read Edit # Optional: pre-approved tools +--- +# Skill Instructions + +Step-by-step markdown instructions here... +``` + +## Adding a New Skill + +> **Important:** The canonical catalog model is 6 router skills with reference markdown. Do not create new top-level skill directories — add new content as reference files within the appropriate router skill. + +1. Identify which router skill your content belongs to (setup, development, deployment, testing, config, or guides) +2. Create a new `.md` reference file in that router's `references/` directory +3. Add a routing entry in the router's `SKILL.md` routing table +4. Run `nx test skills` to validate + +## Manifest Entry + +Each router skill has a corresponding entry in `skills-manifest.json`: + +```json +{ + "name": "frontmcp-development", + "category": "development", + "description": "Domain router for building MCP components", + "path": "frontmcp-development", + "targets": ["all"], + "hasResources": true, + "tags": ["router", "development", "tools", "resources"], + "bundle": ["recommended", "minimal", "full"] +} +``` + +### Target Values + +- `all` — applies to all deployment targets +- `node` — Node.js / Docker deployments +- `vercel` — Vercel serverless +- `lambda` — AWS Lambda +- `cloudflare` — Cloudflare Workers + +### Bundle Values + +- `recommended` — included in default scaffold +- `minimal` — included in minimal scaffold +- `full` — only in full scaffold + +## Scaffold Integration + +Skills are automatically included when scaffolding projects: + +```bash +# CLI (default: recommended bundle) +frontmcp create my-app --skills recommended + +# Nx server generator +nx g @frontmcp/nx:server my-server --skills recommended +``` + +## Validation + +```bash +nx test skills +``` + +Tests verify: + +- All SKILL.md files parse correctly +- Manifest entries match filesystem (no orphans) +- Names match between manifest and frontmatter +- `hasResources` flags are accurate +- Targets, categories, and bundles use valid values diff --git a/libs/skills/__tests__/loader.spec.ts b/libs/skills/__tests__/loader.spec.ts new file mode 100644 index 00000000..1c56381c --- /dev/null +++ b/libs/skills/__tests__/loader.spec.ts @@ -0,0 +1,143 @@ +/** + * Skills catalog loader tests. + */ + +import * as path from 'node:path'; +import { + getSkillsByTarget, + getSkillsByCategory, + getSkillsByBundle, + getInstructionOnlySkills, + getResourceSkills, + resolveSkillPath, + loadManifest, +} from '../src/loader'; +import type { SkillCatalogEntry } from '../src/manifest'; + +const CATALOG_DIR = path.resolve(__dirname, '..', 'catalog'); + +const makeEntry = (overrides: Partial = {}): SkillCatalogEntry => ({ + name: 'test-skill', + category: 'development', + description: 'A test skill', + path: 'development/test-skill', + targets: ['all'], + hasResources: false, + tags: ['test'], + bundle: ['recommended'], + install: { + destinations: ['project-local'], + mergeStrategy: 'overwrite', + }, + ...overrides, +}); + +describe('loader', () => { + describe('loadManifest', () => { + it('should load the bundled manifest', () => { + const manifest = loadManifest(CATALOG_DIR); + expect(manifest).toBeDefined(); + expect(manifest.version).toBe(1); + expect(Array.isArray(manifest.skills)).toBe(true); + }); + }); + + describe('getSkillsByTarget', () => { + const skills = [ + makeEntry({ name: 'all-skill', targets: ['all'] }), + makeEntry({ name: 'node-skill', targets: ['node'] }), + makeEntry({ name: 'vercel-skill', targets: ['vercel'] }), + makeEntry({ name: 'multi-skill', targets: ['node', 'vercel'] }), + ]; + + it('should return skills matching the target', () => { + const result = getSkillsByTarget(skills, 'node'); + expect(result.map((s) => s.name)).toEqual(['all-skill', 'node-skill', 'multi-skill']); + }); + + it('should include all-target skills for any target', () => { + const result = getSkillsByTarget(skills, 'lambda'); + expect(result.map((s) => s.name)).toEqual(['all-skill']); + }); + + it('should return empty for unknown target', () => { + const result = getSkillsByTarget(skills, 'browser'); + expect(result.map((s) => s.name)).toEqual(['all-skill']); + }); + }); + + describe('getSkillsByCategory', () => { + const skills = [ + makeEntry({ name: 'setup-a', category: 'setup' }), + makeEntry({ name: 'deploy-a', category: 'deployment' }), + makeEntry({ name: 'setup-b', category: 'setup' }), + ]; + + it('should filter by category', () => { + const result = getSkillsByCategory(skills, 'setup'); + expect(result.map((s) => s.name)).toEqual(['setup-a', 'setup-b']); + }); + + it('should return empty for missing category', () => { + expect(getSkillsByCategory(skills, 'auth')).toEqual([]); + }); + }); + + describe('getSkillsByBundle', () => { + const skills = [ + makeEntry({ name: 'recommended-a', bundle: ['recommended'] }), + makeEntry({ name: 'minimal-a', bundle: ['minimal', 'recommended'] }), + makeEntry({ name: 'full-only', bundle: ['full'] }), + makeEntry({ name: 'no-bundle', bundle: undefined }), + ]; + + it('should filter by bundle', () => { + const result = getSkillsByBundle(skills, 'recommended'); + expect(result.map((s) => s.name)).toEqual(['recommended-a', 'minimal-a']); + }); + + it('should exclude skills without bundle', () => { + const result = getSkillsByBundle(skills, 'full'); + expect(result.map((s) => s.name)).toEqual(['full-only']); + }); + }); + + describe('getInstructionOnlySkills', () => { + const skills = [ + makeEntry({ name: 'instruction-only', hasResources: false }), + makeEntry({ name: 'with-resources', hasResources: true }), + ]; + + it('should return skills without resources', () => { + const result = getInstructionOnlySkills(skills); + expect(result.map((s) => s.name)).toEqual(['instruction-only']); + }); + }); + + describe('getResourceSkills', () => { + const skills = [ + makeEntry({ name: 'instruction-only', hasResources: false }), + makeEntry({ name: 'with-resources', hasResources: true }), + ]; + + it('should return skills with resources', () => { + const result = getResourceSkills(skills); + expect(result.map((s) => s.name)).toEqual(['with-resources']); + }); + }); + + describe('resolveSkillPath', () => { + it('should resolve path relative to catalog dir', () => { + const entry = makeEntry({ path: 'development/my-tool' }); + const result = resolveSkillPath(entry, '/some/catalog'); + expect(result).toBe(path.resolve('/some/catalog', 'development/my-tool')); + }); + + it('should use default catalog dir when not specified', () => { + const entry = makeEntry({ path: 'setup/my-setup' }); + const result = resolveSkillPath(entry); + // Should resolve relative to the src/../catalog directory + expect(result).toContain('setup/my-setup'); + }); + }); +}); diff --git a/libs/skills/__tests__/manifest.spec.ts b/libs/skills/__tests__/manifest.spec.ts new file mode 100644 index 00000000..e205226d --- /dev/null +++ b/libs/skills/__tests__/manifest.spec.ts @@ -0,0 +1,33 @@ +/** + * Skills manifest validation tests. + */ + +import { VALID_TARGETS, VALID_CATEGORIES, VALID_BUNDLES } from '../src/manifest'; + +describe('manifest constants', () => { + it('should export valid targets', () => { + expect(VALID_TARGETS).toContain('node'); + expect(VALID_TARGETS).toContain('vercel'); + expect(VALID_TARGETS).toContain('lambda'); + expect(VALID_TARGETS).toContain('cloudflare'); + expect(VALID_TARGETS).toContain('all'); + expect(VALID_TARGETS).toHaveLength(5); + }); + + it('should export valid categories', () => { + expect(VALID_CATEGORIES).toContain('setup'); + expect(VALID_CATEGORIES).toContain('deployment'); + expect(VALID_CATEGORIES).toContain('development'); + expect(VALID_CATEGORIES).toContain('config'); + expect(VALID_CATEGORIES).toContain('testing'); + expect(VALID_CATEGORIES).toContain('guides'); + expect(VALID_CATEGORIES).toHaveLength(6); + }); + + it('should export valid bundles', () => { + expect(VALID_BUNDLES).toContain('recommended'); + expect(VALID_BUNDLES).toContain('minimal'); + expect(VALID_BUNDLES).toContain('full'); + expect(VALID_BUNDLES).toHaveLength(3); + }); +}); diff --git a/libs/skills/__tests__/skills-validation.spec.ts b/libs/skills/__tests__/skills-validation.spec.ts new file mode 100644 index 00000000..15427723 --- /dev/null +++ b/libs/skills/__tests__/skills-validation.spec.ts @@ -0,0 +1,418 @@ +/** + * Skills catalog validation tests. + * + * Validates that all SKILL.md files in the catalog: + * - Parse correctly via the SDK's frontmatter parser + * - Have required fields (name, description, body) + * - Are listed in the manifest (no orphans) + * - Match their manifest entries + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseSkillMdFrontmatter, skillMdFrontmatterToMetadata } from '../../sdk/src/skill/skill-md-parser'; +import type { SkillManifest, SkillCatalogEntry } from '../src/manifest'; +import { VALID_TARGETS, VALID_CATEGORIES, VALID_BUNDLES } from '../src/manifest'; + +const CATALOG_DIR = path.resolve(__dirname, '..', 'catalog'); +const MANIFEST_PATH = path.join(CATALOG_DIR, 'skills-manifest.json'); + +function loadManifestSync(): SkillManifest { + const content = fs.readFileSync(MANIFEST_PATH, 'utf-8'); + return JSON.parse(content) as SkillManifest; +} + +function findAllSkillDirs(): string[] { + const dirs: string[] = []; + const entries = fs.readdirSync(CATALOG_DIR).filter((f) => { + const full = path.join(CATALOG_DIR, f); + return fs.statSync(full).isDirectory() && f !== 'node_modules'; + }); + + for (const entry of entries) { + const entryDir = path.join(CATALOG_DIR, entry); + // Skills can be directly in the catalog root (flat structure) + if (fs.existsSync(path.join(entryDir, 'SKILL.md'))) { + dirs.push(entry); + continue; + } + // Or nested inside a category directory (legacy structure) + const skills = fs.readdirSync(entryDir).filter((f) => { + const full = path.join(entryDir, f); + return fs.statSync(full).isDirectory(); + }); + for (const skill of skills) { + const skillDir = path.join(entryDir, skill); + if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) { + dirs.push(`${entry}/${skill}`); + } + } + } + return dirs; +} + +describe('skills catalog validation', () => { + let manifest: SkillManifest; + let skillDirs: string[]; + + beforeAll(() => { + manifest = loadManifestSync(); + skillDirs = findAllSkillDirs(); + }); + + describe('manifest structure', () => { + it('should have version 1', () => { + expect(manifest.version).toBe(1); + }); + + it('should have at least one skill', () => { + expect(manifest.skills.length).toBeGreaterThan(0); + }); + + it('should have unique skill names', () => { + const names = manifest.skills.map((s) => s.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('should have unique paths', () => { + const paths = manifest.skills.map((s) => s.path); + expect(new Set(paths).size).toBe(paths.length); + }); + }); + + describe('manifest entries', () => { + it.each( + // Load manifest lazily to avoid issues with beforeAll timing in .each + (() => { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SkillManifest; + return m.skills.map((s) => [s.name, s] as [string, SkillCatalogEntry]); + })(), + )('"%s" should have valid targets', (_, entry) => { + for (const target of entry.targets) { + expect(VALID_TARGETS).toContain(target); + } + expect(entry.targets.length).toBeGreaterThan(0); + }); + + it.each( + (() => { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SkillManifest; + return m.skills.map((s) => [s.name, s] as [string, SkillCatalogEntry]); + })(), + )('"%s" should have a valid category', (_, entry) => { + expect(VALID_CATEGORIES).toContain(entry.category); + }); + + it.each( + (() => { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SkillManifest; + return m.skills.map((s) => [s.name, s] as [string, SkillCatalogEntry]); + })(), + )('"%s" should have valid bundles if specified', (_, entry) => { + if (entry.bundle) { + for (const b of entry.bundle) { + expect(VALID_BUNDLES).toContain(b); + } + } + }); + + it.each( + (() => { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SkillManifest; + return m.skills.map((s) => [s.name, s] as [string, SkillCatalogEntry]); + })(), + )('"%s" should have a corresponding SKILL.md on disk', (_, entry) => { + const skillMdPath = path.join(CATALOG_DIR, entry.path, 'SKILL.md'); + expect(fs.existsSync(skillMdPath)).toBe(true); + }); + + it.each( + (() => { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SkillManifest; + return m.skills.map((s) => [s.name, s] as [string, SkillCatalogEntry]); + })(), + )('"%s" hasResources should match actual directory contents', (_, entry) => { + const skillPath = path.join(CATALOG_DIR, entry.path); + const hasScripts = fs.existsSync(path.join(skillPath, 'scripts')); + const hasReferences = fs.existsSync(path.join(skillPath, 'references')); + const hasAssets = fs.existsSync(path.join(skillPath, 'assets')); + const actualHasResources = hasScripts || hasReferences || hasAssets; + expect(entry.hasResources).toBe(actualHasResources); + }); + + it.each( + (() => { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SkillManifest; + return m.skills.map((s) => [s.name, s] as [string, SkillCatalogEntry]); + })(), + )('"%s" should have valid install config if present', (_, entry) => { + if (entry.install) { + expect(entry.install.destinations.length).toBeGreaterThan(0); + expect(['overwrite', 'skip-existing']).toContain(entry.install.mergeStrategy); + } + }); + }); + + describe('SKILL.md files', () => { + it.each( + (() => { + const dirs = findAllSkillDirs(); + return dirs.map((d) => [d]); + })(), + )('"%s" should parse with valid frontmatter', (dir) => { + const content = fs.readFileSync(path.join(CATALOG_DIR, dir, 'SKILL.md'), 'utf-8'); + const { frontmatter, body } = parseSkillMdFrontmatter(content); + + expect(frontmatter['name']).toBeDefined(); + expect(typeof frontmatter['name']).toBe('string'); + expect(frontmatter['description']).toBeDefined(); + expect(typeof frontmatter['description']).toBe('string'); + expect(body.length).toBeGreaterThan(0); + }); + + it.each( + (() => { + const dirs = findAllSkillDirs(); + return dirs.map((d) => [d]); + })(), + )('"%s" should produce valid metadata', (dir) => { + const content = fs.readFileSync(path.join(CATALOG_DIR, dir, 'SKILL.md'), 'utf-8'); + const { frontmatter, body } = parseSkillMdFrontmatter(content); + const metadata = skillMdFrontmatterToMetadata(frontmatter, body); + + expect(metadata.name).toBeDefined(); + expect(metadata.description).toBeDefined(); + expect(metadata.instructions).toBeDefined(); + expect((metadata.instructions as string).length).toBeGreaterThan(50); + }); + }); + + describe('dependency resolution', () => { + it('all install.dependencies should reference existing skill names', () => { + const allNames = new Set(manifest.skills.map((s) => s.name)); + const broken: string[] = []; + for (const entry of manifest.skills) { + if (entry.install?.dependencies) { + for (const dep of entry.install.dependencies) { + if (!allNames.has(dep)) { + broken.push(`${entry.name} depends on "${dep}" which does not exist in manifest`); + } + } + } + } + expect(broken).toEqual([]); + }); + }); + + describe('parsed metadata quality', () => { + it.each( + (() => { + const dirs = findAllSkillDirs(); + return dirs.map((d) => [d]); + })(), + )('"%s" should preserve examples after parsing if frontmatter has scenario-based examples', (dir) => { + const content = fs.readFileSync(path.join(CATALOG_DIR, dir, 'SKILL.md'), 'utf-8'); + const { frontmatter, body } = parseSkillMdFrontmatter(content); + const metadata = skillMdFrontmatterToMetadata(frontmatter, body); + + // If frontmatter has examples with 'scenario' key, parsed metadata should preserve them + const rawExamples = frontmatter['examples'] as Array> | undefined; + if (rawExamples && rawExamples.some((e) => 'scenario' in e)) { + expect(metadata.examples?.length).toBeGreaterThan(0); + } + }); + + it.each( + (() => { + const dirs = findAllSkillDirs(); + return dirs.map((d) => [d]); + })(), + )('"%s" should preserve compatibility after parsing if frontmatter has string compatibility', (dir) => { + const content = fs.readFileSync(path.join(CATALOG_DIR, dir, 'SKILL.md'), 'utf-8'); + const { frontmatter, body } = parseSkillMdFrontmatter(content); + const metadata = skillMdFrontmatterToMetadata(frontmatter, body); + + if (typeof frontmatter['compatibility'] === 'string') { + expect(metadata.compatibility).toBeDefined(); + } + }); + }); + + describe('manifest <-> filesystem sync', () => { + it('every SKILL.md directory should be listed in the manifest', () => { + const manifestPaths = new Set(manifest.skills.map((s) => s.path)); + const orphans = skillDirs.filter((d) => !manifestPaths.has(d)); + expect(orphans).toEqual([]); + }); + + it('every manifest entry should have a SKILL.md on disk', () => { + const missing = manifest.skills.filter((s) => !fs.existsSync(path.join(CATALOG_DIR, s.path, 'SKILL.md'))); + expect(missing.map((s) => s.name)).toEqual([]); + }); + + it('manifest names should match SKILL.md frontmatter names', () => { + const mismatches: string[] = []; + for (const entry of manifest.skills) { + const content = fs.readFileSync(path.join(CATALOG_DIR, entry.path, 'SKILL.md'), 'utf-8'); + const { frontmatter } = parseSkillMdFrontmatter(content); + if (frontmatter['name'] !== entry.name) { + mismatches.push(`${entry.name}: manifest="${entry.name}" vs SKILL.md="${frontmatter['name']}"`); + } + } + expect(mismatches).toEqual([]); + }); + + it('manifest descriptions should match SKILL.md frontmatter descriptions', () => { + const mismatches: string[] = []; + for (const entry of manifest.skills) { + const content = fs.readFileSync(path.join(CATALOG_DIR, entry.path, 'SKILL.md'), 'utf-8'); + const { frontmatter } = parseSkillMdFrontmatter(content); + const mdDesc = frontmatter['description'] as string | undefined; + if (mdDesc && mdDesc !== entry.description) { + mismatches.push(`${entry.name}: manifest description differs from SKILL.md frontmatter`); + } + } + expect(mismatches).toEqual([]); + }); + }); + + describe('semantic content validation', () => { + /** + * Collects all .md files under references/ for all catalog skills. + */ + function getAllReferenceFiles(): { skill: string; file: string; fullPath: string }[] { + const results: { skill: string; file: string; fullPath: string }[] = []; + const entries = fs.readdirSync(CATALOG_DIR).filter((f) => { + const full = path.join(CATALOG_DIR, f); + return fs.statSync(full).isDirectory() && f !== 'node_modules'; + }); + for (const entry of entries) { + const refsDir = path.join(CATALOG_DIR, entry, 'references'); + if (fs.existsSync(refsDir)) { + const files = fs.readdirSync(refsDir).filter((f) => f.endsWith('.md')); + for (const file of files) { + results.push({ skill: entry, file, fullPath: path.join(refsDir, file) }); + } + } + // Also include the SKILL.md itself + const skillMd = path.join(CATALOG_DIR, entry, 'SKILL.md'); + if (fs.existsSync(skillMd)) { + results.push({ skill: entry, file: 'SKILL.md', fullPath: skillMd }); + } + } + return results; + } + + it('should not use invalid LLM "adapter" field in code examples', () => { + const violations: string[] = []; + for (const { skill, file, fullPath } of getAllReferenceFiles()) { + const content = fs.readFileSync(fullPath, 'utf-8'); + // Match adapter: 'anthropic' or adapter: 'openai' in code blocks + const adapterMatches = content.match(/adapter:\s*['"](?:anthropic|openai)['"]/g); + if (adapterMatches) { + violations.push(`${skill}/${file}: found ${adapterMatches.length}x "adapter:" — should be "provider:"`); + } + } + expect(violations).toEqual([]); + }); + + it('should not use auth string shorthand in decorator context', () => { + const violations: string[] = []; + for (const { skill, file, fullPath } of getAllReferenceFiles()) { + const content = fs.readFileSync(fullPath, 'utf-8'); + // Match auth: 'remote', auth: 'public', auth: 'transparent' as standalone config values + const authShorthand = content.match(/auth:\s*['"](?:remote|public|transparent)['"]/g); + if (authShorthand) { + violations.push(`${skill}/${file}: found auth string shorthand — should be auth: { mode: '...' }`); + } + } + expect(violations).toEqual([]); + }); + + it('should not use "streamable-http" as a transport preset in SDK context', () => { + const violations: string[] = []; + const validPresets = ['modern', 'legacy', 'stateless-api', 'full']; + for (const { skill, file, fullPath } of getAllReferenceFiles()) { + const content = fs.readFileSync(fullPath, 'utf-8'); + // Match protocol: 'streamable-http' or transport: 'streamable-http' + const matches = content.match(/(?:protocol|transport):\s*['"]streamable-http['"]/g); + if (matches) { + violations.push( + `${skill}/${file}: found "streamable-http" preset — valid presets are: ${validPresets.join(', ')}`, + ); + } + } + expect(violations).toEqual([]); + }); + + it('should not use bare @App() without metadata', () => { + const violations: string[] = []; + for (const { skill, file, fullPath } of getAllReferenceFiles()) { + const content = fs.readFileSync(fullPath, 'utf-8'); + // Match @App() with empty parens (no arguments) + const bareApp = content.match(/@App\(\s*\)/g); + if (bareApp) { + violations.push(`${skill}/${file}: found bare @App() — must include { name: '...' }`); + } + } + expect(violations).toEqual([]); + }); + + it('should not use "session:" as a top-level @FrontMcp field', () => { + const violations: string[] = []; + for (const { skill, file, fullPath } of getAllReferenceFiles()) { + const content = fs.readFileSync(fullPath, 'utf-8'); + // Look for session: { ... } in decorator blocks (preceded by @FrontMcp) + // Simple heuristic: find session: { store in code blocks + const sessionStore = content.match(/session:\s*\{\s*\n?\s*store:/g); + if (sessionStore) { + violations.push(`${skill}/${file}: found top-level "session:" field — use "redis:" at top level instead`); + } + } + expect(violations).toEqual([]); + }); + }); + + describe('new-format migration tracking', () => { + function getSkillBody(dir: string): string { + return fs.readFileSync(path.join(CATALOG_DIR, dir, 'SKILL.md'), 'utf-8'); + } + + it('should track migration progress across the catalog', () => { + let migrated = 0; + const total = skillDirs.length; + for (const dir of skillDirs) { + const content = getSkillBody(dir); + const hasNewWhenToUse = content.includes('## When to Use This Skill') && content.includes('### Must Use'); + if (hasNewWhenToUse) { + migrated++; + } + } + // Log migration progress for visibility + + console.log(`[migration] ${migrated}/${total} skills migrated to new format`); + // This will pass regardless -- it's a progress tracker, not a gate + expect(migrated).toBeGreaterThanOrEqual(0); + }); + + it.each( + (() => { + const dirs = findAllSkillDirs(); + return dirs.map((d) => [d]); + })(), + )('"%s" migrated skills should have all required new-format sections', (dir) => { + const content = getSkillBody(dir); + const isMigrated = content.includes('## When to Use This Skill') && content.includes('### Must Use'); + if (!isMigrated) { + // Skip validation for unmigrated skills + return; + } + + // Migrated skills must have the full new structure + expect(content).toContain('### Must Use'); + expect(content).toContain('### Recommended'); + expect(content).toContain('### Skip When'); + expect(content).toContain('## Verification Checklist'); + }); + }); +}); diff --git a/libs/skills/catalog/TEMPLATE.md b/libs/skills/catalog/TEMPLATE.md new file mode 100644 index 00000000..d5145c20 --- /dev/null +++ b/libs/skills/catalog/TEMPLATE.md @@ -0,0 +1,94 @@ +--- +name: skill-name +description: Primary action sentence. Use when [scenario 1], [scenario 2], or [scenario 3]. +tags: [category, keyword1, keyword2] +tools: + - name: tool_name + purpose: What this tool does in this skill +parameters: + - name: param_name + description: What this parameter controls + type: string + default: default_value +examples: + - scenario: When to use this skill + expected-outcome: What the user should see after completion +priority: 7 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/... +--- + +# Skill Title + +One-paragraph overview of what this skill accomplishes and its role in the FrontMCP ecosystem. + +## When to Use This Skill + +### Must Use + +- Scenario where this is the only correct skill to apply +- Another mandatory scenario + +### Recommended + +- Scenario where this skill helps but alternatives exist +- Helpful but optional scenario + +### Skip When + +- Scenario where another skill is the better choice (see `other-skill-name`) +- Situation where this skill does not apply + +> **Decision:** One-liner summarizing when to pick this skill over alternatives. + +## Prerequisites + +- Required packages or tools +- Prior skills that should be completed first (see `prerequisite-skill`) + +## Steps + +### Step 1: First Action + +Describe the first step with code examples: + +```typescript +// Example code +``` + +### Step 2: Second Action + +Continue with subsequent steps. + +## Common Patterns + + + +| Pattern | Correct | Incorrect | Why | +| --------------- | ------------------------ | -------------- | ------------------------------------ | +| Decorator usage | `@Tool({ name: '...' })` | `@Tool('...')` | Decorator requires an options object | + +## Verification Checklist + +### Configuration + +- [ ] Config item verified +- [ ] Dependencies installed + +### Runtime + +- [ ] Feature works as expected +- [ ] Error cases handled + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------- | -------------- | ------------- | +| Common error message | Why it happens | How to fix it | + +## Reference + +- [Documentation](https://docs.agentfront.dev/frontmcp/...) +- Related skills: `related-skill-a`, `related-skill-b` diff --git a/libs/skills/catalog/frontmcp-config/SKILL.md b/libs/skills/catalog/frontmcp-config/SKILL.md new file mode 100644 index 00000000..9fe603ed --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/SKILL.md @@ -0,0 +1,140 @@ +--- +name: frontmcp-config +description: "Domain router for configuring MCP servers \u2014 transport, HTTP, throttle, elicitation, auth, sessions, and storage. Use when configuring any aspect of a FrontMCP server." +tags: [router, config, transport, http, auth, session, redis, sqlite, throttle, guide] +priority: 10 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/configuration/overview +--- + +# FrontMCP Configuration Router + +Entry point for configuring FrontMCP servers. This skill helps you find the right configuration skill based on what aspect of your server you need to set up. + +## When to Use This Skill + +### Must Use + +- Setting up a new server and need to understand which configuration options exist +- Deciding between authentication modes, transport protocols, or storage backends +- Planning server configuration across transport, auth, throttling, and storage + +### Recommended + +- Looking up which skill covers a specific config option (CORS, rate limits, session TTL, etc.) +- Understanding how configuration layers work (server-level vs app-level vs tool-level) +- Reviewing the full configuration surface area before production deployment + +### Skip When + +- You already know which config area to change (go directly to `configure-transport`, `configure-auth`, etc.) +- You need to build components, not configure the server (see `frontmcp-development`) +- You need to deploy, not configure (see `frontmcp-deployment`) + +> **Decision:** Use this skill when you need to figure out WHAT to configure. Use the specific skill when you already know. + +## Prerequisites + +- A FrontMCP project scaffolded with `frontmcp create` (see `frontmcp-setup`) +- Node.js 22+ and npm/yarn installed + +## Steps + +1. Identify the configuration area you need using the Scenario Routing Table below +2. Navigate to the specific configuration skill (e.g., `configure-transport`, `configure-auth`) for detailed instructions +3. Apply the configuration in your `@FrontMcp` or `@App` decorator +4. Verify using the Verification Checklist at the end of this skill + +## Scenario Routing Table + +| Scenario | Skill | Description | +| ---------------------------------------------------------- | ----------------------- | ------------------------------------------------------------- | +| Choose between SSE, Streamable HTTP, or stdio | `configure-transport` | Transport protocol selection with distributed session options | +| Set up CORS, port, base path, or request limits | `configure-http` | HTTP server options for Streamable HTTP and SSE transports | +| Add rate limiting, concurrency, or IP filtering | `configure-throttle` | Server-level and per-tool throttle configuration | +| Enable tools to ask users for input | `configure-elicitation` | Elicitation schemas, stores, and multi-step flows | +| Set up authentication (public, transparent, local, remote) | `configure-auth` | OAuth flows, credential vault, multi-app auth | +| Configure session storage backends | `configure-session` | Memory, Redis, Vercel KV, and custom session stores | +| Add Redis for production storage | `setup-redis` | Docker Redis, Vercel KV, pub/sub for subscriptions | +| Add SQLite for local development | `setup-sqlite` | SQLite with WAL mode, migration helpers | + +## Configuration Layers + +FrontMCP configuration cascades through three layers: + +```text +Server (@FrontMcp) ← Global defaults + └── App (@App) ← App-level overrides + └── Tool (@Tool) ← Per-tool overrides +``` + +| Setting | Server | App | Tool | +| --------------------- | ------------ | --- | -------------- | +| Transport | Yes | No | No | +| HTTP (CORS, port) | Yes | No | No | +| Throttle (rate limit) | Yes (global) | No | Yes (per-tool) | +| Auth mode | Yes | Yes | No | +| Session store | Yes | No | No | +| Elicitation | No | No | Yes (per-tool) | + +## Cross-Cutting Patterns + +| Pattern | Rule | +| ------------------- | ------------------------------------------------------------------------------------------------- | +| Auth + session | Auth mode determines session requirements: `remote` needs Redis/KV; `public` can use memory | +| Transport + storage | Stateless transports (serverless) require distributed storage; stateful (Node) can use in-process | +| Throttle scope | Server-level throttle applies to all tools; per-tool throttle overrides for specific tools | +| Environment config | Use environment variables for all secrets (API keys, Redis URLs, OAuth credentials) | +| Config validation | FrontMCP validates config at startup; invalid config throws before the server starts | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| Auth mode for dev | `auth: { mode: 'public' }` or `auth: { mode: 'transparent', provider: '...' }` locally | `auth: { mode: 'remote', ... }` with real OAuth in dev | Remote auth requires a running OAuth provider; public/transparent are simpler for local dev | +| Session store | Redis for production, memory for development | Memory for production | Memory sessions are lost on restart and don't work across serverless invocations | +| Rate limit placement | Server-level for global limits, per-tool for expensive operations | Only server-level | Some tools are cheap (list) and some are expensive (generate); per-tool limits prevent abuse of expensive tools | +| CORS config | Explicit allowed origins in production | `cors: { origin: '*' }` in production | Wildcard CORS allows any origin to call your server | +| Config secrets | `process.env.REDIS_URL` via environment variable | Hardcoded `redis://localhost:6379` in source | Hardcoded secrets leak to git and break in different environments | + +## Verification Checklist + +### Transport and HTTP + +- [ ] Transport protocol configured and server starts without errors +- [ ] CORS allows expected origins (test with browser or curl) +- [ ] Port and base path accessible from client + +### Authentication + +- [ ] Auth mode set appropriately for the environment (public/transparent for dev, remote for prod) +- [ ] OAuth credentials stored in environment variables, not source code +- [ ] Session store configured with appropriate backend (memory for dev, Redis for prod) + +### Throttle and Security + +- [ ] Global rate limit configured to prevent abuse +- [ ] Expensive tools have per-tool throttle overrides +- [ ] IP allow/deny lists configured if needed + +### Storage + +- [ ] Redis or SQLite configured and connectable +- [ ] Storage persists across server restarts (not memory in production) + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| Server fails to start with config error | Invalid or missing required config field | Check the error message; FrontMCP validates config at startup and reports the specific invalid field | +| CORS blocked in browser | Missing or incorrect CORS origin config | Add the client's origin to `http.cors.origin`; see `configure-http` | +| Rate limit too aggressive | Global limit applied to all tools | Add per-tool overrides for cheap tools with higher limits; see `configure-throttle` | +| Sessions lost on serverless | Using memory session store on stateless platform | Switch to Redis or Vercel KV; see `configure-session` | +| Auth callback fails | OAuth redirect URI mismatch | Ensure the callback URL in your OAuth provider matches `auth.callbackUrl`; see `configure-auth` | + +## Reference + +- [Configuration Overview](https://docs.agentfront.dev/frontmcp/configuration/overview) +- Related skills: `configure-transport`, `configure-http`, `configure-throttle`, `configure-elicitation`, `configure-auth`, `configure-session`, `setup-redis`, `setup-sqlite` diff --git a/libs/skills/catalog/frontmcp-config/references/configure-auth-modes.md b/libs/skills/catalog/frontmcp-config/references/configure-auth-modes.md new file mode 100644 index 00000000..c9b789bd --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-auth-modes.md @@ -0,0 +1,77 @@ +# Auth Modes Detailed Comparison + +## Public Mode + +No authentication required. All requests get anonymous access. + +```typescript +auth: { + mode: 'public', + sessionTtl: 3600, + anonymousScopes: ['read', 'write'], + publicAccess: { tools: true, resources: true, prompts: true }, +} +``` + +**Use when:** Development, internal tools, public APIs. + +## Transparent Mode + +Server validates tokens from an upstream identity provider. Does not issue or refresh tokens. + +```typescript +auth: { + mode: 'transparent', + provider: 'https://auth.example.com', + expectedAudience: 'my-api', + clientId: 'my-client-id', +} +``` + +**Use when:** Behind an API gateway or reverse proxy that handles auth. + +## Local Mode + +Server signs its own JWT tokens. Full control over token lifecycle. + +```typescript +auth: { + mode: 'local', + local: { + issuer: 'my-server', + audience: 'my-api', + }, + tokenStorage: 'redis', + consent: { enabled: true }, + incrementalAuth: { enabled: true }, +} +``` + +**Use when:** Standalone servers with full auth control, development with local OAuth. + +## Remote Mode + +Server delegates to an upstream auth orchestrator for token management. + +```typescript +auth: { + mode: 'remote', + provider: 'https://auth.example.com', + clientId: 'my-client-id', + clientSecret: process.env.AUTH_SECRET, + tokenStorage: 'redis', +} +``` + +**Use when:** Enterprise deployments with centralized identity management. + +## Comparison Table + +| Feature | Public | Transparent | Local | Remote | +| ---------------- | ------------- | --------------- | ----------- | ------------ | +| Token issuance | Anonymous JWT | None (upstream) | Self-signed | Orchestrator | +| Token refresh | No | No | Yes | Yes | +| PKCE support | No | No | Yes | Yes | +| Credential vault | No | No | Yes | Yes | +| Consent flow | No | No | Optional | Optional | +| Federated auth | No | No | Optional | Optional | diff --git a/libs/skills/catalog/frontmcp-config/references/configure-auth.md b/libs/skills/catalog/frontmcp-config/references/configure-auth.md new file mode 100644 index 00000000..dc30d673 --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-auth.md @@ -0,0 +1,238 @@ +# Configure Authentication for FrontMCP + +This skill covers setting up authentication in a FrontMCP server. FrontMCP supports four auth modes, each suited to different deployment scenarios. All authentication logic lives in the `@frontmcp/auth` library. + +## When to Use This Skill + +### Must Use + +- Adding authentication to a new FrontMCP server for the first time +- Switching between auth modes (e.g., moving from `public` to `remote` for production) +- Configuring the credential vault to access downstream APIs on behalf of authenticated users + +### Recommended + +- Setting up multi-app auth where different `@App` instances need different security postures +- Configuring OAuth local dev flow for development against `remote` or `transparent` modes +- Adding audience validation or session TTL tuning to an existing auth setup + +### Skip When + +- You need to manage session storage backends (Redis, Vercel KV) -- use `configure-session` instead +- You are building a plugin that extends auth context -- use `create-plugin` instead + +> **Decision:** Use this skill whenever you need to choose, configure, or change the authentication mode on a FrontMCP server. + +## Auth Modes Overview + +| Mode | Use Case | Token Issuer | +| ------------- | ------------------------------------------ | ------------------- | +| `public` | Open access with optional scoping | None | +| `transparent` | Validate externally-issued JWTs | External provider | +| `local` | Server signs its own tokens | The FrontMCP server | +| `remote` | Full OAuth 2.1 flow with external provider | External provider | + +## Mode 1: Public + +Public mode allows all connections without authentication. Use this for development or open APIs where access control is handled elsewhere. + +```typescript +@App({ + auth: { + mode: 'public', + sessionTtl: 3600, + anonymousScopes: ['read'], + }, +}) +class MyApp {} +``` + +- `sessionTtl` -- session lifetime in seconds. +- `anonymousScopes` -- scopes granted to all unauthenticated clients. + +## Mode 2: Transparent + +Transparent mode validates JWTs issued by an external provider without initiating an OAuth flow. The server fetches the provider's JWKS to verify token signatures. + +```typescript +@App({ + auth: { + mode: 'transparent', + provider: 'https://auth.example.com', + expectedAudience: 'my-api', + }, +}) +class MyApp {} +``` + +- `provider` -- the authorization server URL. FrontMCP fetches JWKS from `{provider}/.well-known/jwks.json`. +- `expectedAudience` -- the `aud` claim value that tokens must contain. + +Use transparent mode when clients already have tokens from your identity provider and the server only needs to verify them. + +## Mode 3: Local + +Local mode lets the FrontMCP server sign its own JWT tokens. This is useful for internal services or environments where an external identity provider is not available. + +```typescript +@App({ + auth: { + mode: 'local', + local: { + issuer: 'my-server', + }, + }, +}) +class MyApp {} +``` + +- `local.issuer` -- the `iss` claim set in generated tokens (defaults to server URL if omitted). + +The server generates a signing key pair on startup (or loads one from the configured key store). Clients obtain tokens through a server-provided endpoint. + +## Mode 4: Remote + +Remote mode performs a full OAuth 2.1 authorization flow with an external provider. Clients are redirected to the provider for authentication and return with an authorization code. + +```typescript +@App({ + auth: { + mode: 'remote', + provider: 'https://auth.example.com', + clientId: 'xxx', + }, +}) +class MyApp {} +``` + +- `provider` -- the OAuth 2.1 authorization server URL. +- `clientId` -- the OAuth client identifier registered with the provider. + +## OAuth Local Dev Flow + +For local development with `remote` or `transparent` mode, you can skip the full OAuth flow by setting the environment to development: + +```typescript +@App({ + auth: { + mode: 'remote', + provider: 'https://auth.example.com', + clientId: 'dev-client-id', + }, +}) +class MyApp {} +``` + +When `NODE_ENV=development`, FrontMCP relaxes token validation to support local identity provider instances (e.g., a local Keycloak or mock OAuth server). Tokens are still validated, but HTTPS requirements and strict issuer checks are loosened. + +## Multi-App Auth + +Each `@App` in a FrontMCP server can have a different auth configuration. This is useful when a single server hosts multiple logical applications with different security requirements: + +```typescript +@App({ + name: 'public-api', + auth: { + mode: 'public', + sessionTtl: 3600, + anonymousScopes: ['read'], + }, + tools: [PublicSearchTool, PublicInfoTool], +}) +class PublicApi {} + +@App({ + name: 'admin-api', + auth: { + mode: 'remote', + provider: 'https://auth.example.com', + clientId: 'admin-client', + }, + tools: [AdminTool, ConfigTool], +}) +class AdminApi {} +``` + +## Credential Vault + +The credential vault stores downstream API tokens obtained during the OAuth flow. Use it when your MCP tools need to call external APIs on behalf of the authenticated user. Credential vault is managed through the auth provider's OAuth flow — downstream tokens are stored automatically when users authorize external services. + +```typescript +@App({ + name: 'MyApp', + auth: { + mode: 'remote', + provider: 'https://auth.example.com', + clientId: 'mcp-client-id', + }, +}) +class MyApp {} +``` + +Tools access downstream credentials via the `this.authProviders` context extension: + +```typescript +@Tool({ name: 'create_github_issue' }) +class CreateGithubIssueTool extends ToolContext { + async execute(input: { title: string; body: string }) { + // Access downstream credentials via the authProviders context extension + const github = await this.authProviders.get('github'); + const headers = await this.authProviders.headers('github'); + // Use headers to call GitHub API + } +} +``` + +The `authProviders` accessor (from `@frontmcp/auth`) provides: + +- `get(provider)` -- get the credential/token for a provider. +- `headers(provider)` -- get pre-formatted auth headers for HTTP requests. +- `has(provider)` -- check if a provider is configured. +- `refresh(provider)` -- force refresh the credential. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Session store in production | Use Redis or Vercel KV session store | Use default in-memory session store | Sessions are lost on restart; in-memory does not survive process recycling | +| Secret management | Load `clientId`, vault secrets, and Redis passwords from environment variables | Hardcode secrets in source code | Hardcoded secrets leak into version control and are difficult to rotate | +| Audience validation | Always set `expectedAudience` in transparent/remote mode | Omit the audience field | Without audience validation, tokens issued for any audience would be accepted | +| Auth mode for development | Use `public` mode or local OAuth mock for dev environments | Use `remote` mode pointing at production IdP during development | Avoids accidental production token usage and simplifies local iteration | +| Vault encryption secret | Generate a strong random secret and store in env var `VAULT_SECRET` | Use a short or predictable string for vault encryption | Weak secrets compromise all stored downstream credentials | + +## Verification Checklist + +**Configuration** + +- [ ] Auth mode is set to the correct value for the deployment target (`public`, `transparent`, `local`, or `remote`) +- [ ] `provider` URL is set when using `transparent` or `remote` mode +- [ ] `clientId` is configured when using `remote` mode +- [ ] `expectedAudience` is set when using `transparent` mode + +**Security** + +- [ ] No secrets are hardcoded in source files -- all loaded from environment variables +- [ ] Vault encryption secret is a strong random value +- [ ] Production deployments use Redis or Vercel KV for session storage, not in-memory + +**Runtime** + +- [ ] Server starts without auth-related errors in the console +- [ ] Tokens are validated correctly (test with a valid and an invalid token) +- [ ] Downstream credential vault returns tokens for configured providers +- [ ] Multi-app configurations route requests to the correct auth mode per app + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JWKS fetch failed` error on startup | The `provider` URL is unreachable or does not serve `/.well-known/jwks.json` | Verify the provider URL is correct and accessible from the server; check network/firewall rules | +| Tokens rejected with `invalid audience` | The `expectedAudience` value does not match the `aud` claim in the token | Align the `expectedAudience` config with the audience value your identity provider sets in tokens | +| Sessions lost after server restart | Using the default in-memory session store in production | Switch to Redis or Vercel KV session store via `configure-session` reference | +| `VAULT_SECRET is not defined` error | The vault encryption secret environment variable is missing | Set `VAULT_SECRET` in your environment or `.env` file before starting the server | +| OAuth redirect fails in local dev | `remote` mode requires HTTPS and reachable callback URLs | Set `NODE_ENV=development` to relax HTTPS requirements, or use a local OAuth mock server | + +## Reference + +- Docs: [Authentication Overview](https://docs.agentfront.dev/frontmcp/authentication/overview) +- Related skills: `configure-session`, `create-plugin` diff --git a/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md b/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md new file mode 100644 index 00000000..0114e1d0 --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md @@ -0,0 +1,178 @@ +# Configuring Elicitation + +Elicitation allows tools to request interactive input from users mid-execution — confirmations, choices, or structured form data. + +## When to Use This Skill + +### Must Use + +- Tools need user confirmation before destructive actions (delete, deploy, overwrite) +- Building interactive multi-step workflows that require user decisions mid-execution +- Tools need structured form input from the user during execution (e.g., parameter selection, file choice) + +### Recommended + +- Adding a safety gate to tools that modify external systems (databases, APIs, deployments) +- Implementing approval flows where a tool must get explicit consent before proceeding +- Multi-instance production deployments where elicitation state must be shared via Redis + +### Skip When + +- Tools are fully autonomous and never need user input -- elicitation adds overhead when unused +- The MCP client does not support elicitation -- check client capabilities first (see Notes section) +- Only need input validation, not mid-execution prompts -- use Zod input schemas on the `@Tool` decorator + +> **Decision:** Use this skill when tools need to pause execution and request interactive input from the user; skip if all tools run autonomously without user interaction. + +## Enable Elicitation + +### Basic (In-Memory) + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + elicitation: { + enabled: true, + }, +}) +class Server {} +``` + +### With Redis (Distributed/Production) + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + elicitation: { + enabled: true, + redis: { provider: 'redis', host: 'localhost', port: 6379 }, + }, +}) +class Server {} +``` + +## ElicitationOptionsInput + +```typescript +interface ElicitationOptionsInput { + enabled?: boolean; // default: false + redis?: RedisOptionsInput; // storage for elicitation state +} +``` + +## Using Elicitation in Tools + +When elicitation is enabled, tools can request user input via the MCP elicitation protocol: + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'delete_records', + description: 'Delete records from the database', + inputSchema: { + table: z.string(), + filter: z.string(), + }, + outputSchema: { deleted: z.number() }, +}) +class DeleteRecordsTool extends ToolContext { + async execute(input: { table: string; filter: string }) { + // Count records that would be deleted + const db = this.get(DB_TOKEN); + const count = await db.count(input.table, input.filter); + + // Request confirmation from user before proceeding + const confirmation = await this.elicit({ + message: `This will delete ${count} records from ${input.table}. Are you sure?`, + requestedSchema: { + type: 'object', + properties: { + confirmed: { type: 'boolean', description: 'Confirm deletion' }, + }, + required: ['confirmed'], + }, + }); + + if (!confirmation || !confirmation.confirmed) { + return { deleted: 0 }; + } + + const deleted = await db.delete(input.table, input.filter); + return { deleted }; + } +} +``` + +## How It Works + +1. Tool calls `this.elicit()` with a message and requested schema +2. Server sends an `elicitation/request` to the client +3. Client displays the request to the user (UI varies by client) +4. User responds with structured data matching the schema +5. `this.elicit()` returns the user's response +6. Tool continues execution with the response + +## Notes + +- When `enabled: false` (default), `this.elicit()` is not available — keeps resource overhead low +- When enabled, tool output schemas are automatically extended with elicitation fallback type +- Use Redis storage for production/multi-instance deployments +- Not all MCP clients support elicitation — handle gracefully when `this.elicit()` returns `undefined` + +## Verification + +```bash +# Enable elicitation and start +frontmcp dev + +# Test with an MCP client that supports elicitation +# The tool should pause and request user input +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ---------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Handling unsupported clients | Check `if (!confirmation)` after `this.elicit()` and provide a fallback | Assuming `this.elicit()` always returns a value | Not all MCP clients support elicitation; `undefined` is returned when the client cannot handle the request | +| Schema for confirmation | Use `{ confirmed: { type: 'boolean' } }` in `requestedSchema` | Using a plain string prompt without a schema | Structured schemas let the client render proper UI controls (checkboxes, dropdowns) instead of free-text input | +| Redis for production | Set `elicitation: { enabled: true, redis: { provider: 'redis', ... } }` | Using in-memory elicitation state in a multi-instance deployment | In-memory state is per-process; if the response arrives at a different instance, the elicitation context is lost | +| Enabled flag | Explicitly set `elicitation: { enabled: true }` | Omitting the `enabled` field and expecting elicitation to work | Elicitation is disabled by default (`enabled: false`) to minimize resource overhead | + +## Verification Checklist + +### Configuration + +- [ ] `elicitation.enabled` is set to `true` in the `@FrontMcp` decorator +- [ ] For production/multi-instance: `elicitation.redis` is configured with a valid Redis provider +- [ ] The `requestedSchema` in `this.elicit()` calls uses valid JSON Schema objects + +### Runtime + +- [ ] Tool execution pauses when `this.elicit()` is called and the client supports elicitation +- [ ] The user sees a prompt or form matching the requested schema +- [ ] After the user responds, `this.elicit()` returns the structured data and the tool resumes +- [ ] When the client does not support elicitation, `this.elicit()` returns `undefined` and the tool handles the fallback gracefully + +### Integration + +- [ ] MCP client under test advertises elicitation support in its capabilities +- [ ] Destructive tools have elicitation-based confirmation gates before proceeding + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `this.elicit is not a function` | Elicitation is not enabled in the server configuration | Set `elicitation: { enabled: true }` in the `@FrontMcp` decorator | +| `this.elicit()` returns `undefined` immediately | The connected MCP client does not support elicitation | Check client capabilities; provide a fallback code path for unsupported clients | +| Elicitation works locally but fails in production | In-memory store loses state across multiple server instances | Configure `elicitation.redis` to share elicitation state via Redis | +| User sees raw JSON instead of a form | The MCP client renders the `requestedSchema` as raw data rather than a form | Use standard JSON Schema types (`boolean`, `string`, `enum`) that clients can render as UI controls | +| Tool hangs indefinitely waiting for user response | No timeout configured and user never responds | Implement a timeout or cancellation mechanism in the tool logic to handle non-responsive users | + +## Reference + +- [Elicitation Docs](https://docs.agentfront.dev/frontmcp/servers/elicitation) +- Related skills: `configure-http`, `configure-transport`, `setup-redis`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-config/references/configure-http.md b/libs/skills/catalog/frontmcp-config/references/configure-http.md new file mode 100644 index 00000000..efb903fe --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-http.md @@ -0,0 +1,205 @@ +# Configuring HTTP Options + +Configure the HTTP server — port, CORS policy, unix sockets, and entry path prefix. + +## When to Use This Skill + +### Must Use + +- Changing the default HTTP port or binding to a specific network interface +- Enabling or restricting CORS for a frontend application that calls the MCP server +- Binding to a unix socket for local daemon or process-manager integrations + +### Recommended + +- Mounting the MCP server under a URL prefix behind a reverse proxy +- Setting a dynamic port from an environment variable for container deployments +- Fine-tuning CORS preflight caching for performance-sensitive frontends + +### Skip When + +- Using stdio transport only with no HTTP listener -- no HTTP options apply +- Only need rate limiting or IP filtering without changing HTTP binding -- use `configure-throttle` +- Need to configure TLS/HTTPS termination -- handle at the reverse proxy or load balancer level, not in FrontMCP + +> **Decision:** Use this skill when you need to customize how the HTTP listener binds (port, socket, prefix) or how it handles CORS; skip if the default port 3001 with permissive CORS is sufficient. + +## HttpOptionsInput + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + http: { + port: 3001, // default: 3001 + entryPath: '', // default: '' (root) + socketPath: undefined, // unix socket path (overrides port) + cors: { + // default: permissive (all origins) + origin: ['https://myapp.com'], + credentials: true, + maxAge: 86400, + }, + }, +}) +class Server {} +``` + +## Port Configuration + +```typescript +// Default: port 3001 +http: { + port: 3001; +} + +// Use environment variable +http: { + port: Number(process.env.PORT) || 3001; +} + +// Random port (useful for testing) +http: { + port: 0; +} +``` + +## CORS Configuration + +### Permissive (Default) + +When `cors` is not specified, the server allows all origins without credentials: + +```typescript +// All origins allowed (default behavior) +http: { +} +``` + +### Restrict to Specific Origins + +```typescript +http: { + cors: { + origin: ['https://myapp.com', 'https://staging.myapp.com'], + credentials: true, + maxAge: 86400, // Cache preflight for 24 hours + }, +} +``` + +### Disable CORS Entirely + +```typescript +http: { + cors: false, // No CORS headers at all +} +``` + +### Dynamic Origin + +```typescript +http: { + cors: { + origin: (origin: string) => { + // Allow any *.myapp.com subdomain + return origin.endsWith('.myapp.com'); + }, + credentials: true, + }, +} +``` + +### CORS Fields + +| Field | Type | Default | Description | +| ------------- | ------------------------------------------- | ------------ | ---------------------------------- | +| `origin` | `boolean \| string \| string[] \| function` | `true` (all) | Allowed origins | +| `credentials` | `boolean` | `false` | Allow cookies/auth headers | +| `maxAge` | `number` | — | Preflight cache duration (seconds) | + +## Entry Path Prefix + +Mount the MCP server under a URL prefix: + +```typescript +http: { + entryPath: '/api/mcp', +} +// Server endpoints become: /api/mcp/sse, /api/mcp/, etc. +``` + +Useful when running behind a reverse proxy or alongside other services. + +## Unix Socket Mode + +Bind to a unix socket instead of a TCP port for local-only access: + +```typescript +http: { + socketPath: '/tmp/my-mcp-server.sock', +} +``` + +- Mutually exclusive with `port` — if `socketPath` is set, `port` is ignored +- Use for local daemons, CLI tools, and process manager integrations +- Combine with `sqlite` for fully local deployments + +## Verification + +```bash +# Start with custom port +PORT=8080 frontmcp dev + +# Test CORS +curl -v -H "Origin: https://myapp.com" http://localhost:8080/ + +# Test unix socket +curl --unix-socket /tmp/my-mcp-server.sock http://localhost/ +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------- | ------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Port from environment | `port: Number(process.env.PORT) \|\| 3001` | `port: process.env.PORT` | The `port` field expects a number; passing a string causes a silent bind failure | +| CORS with credentials | `cors: { origin: ['https://myapp.com'], credentials: true }` | `cors: { origin: true, credentials: true }` | Browsers reject `Access-Control-Allow-Origin: *` when credentials are enabled; you must list explicit origins | +| Unix socket mode | `socketPath: '/tmp/my-mcp.sock'` with no `port` field | Setting both `socketPath` and `port` | When `socketPath` is set, `port` is silently ignored which can cause confusion during debugging | +| Entry path prefix | `entryPath: '/api/mcp'` (no trailing slash) | `entryPath: '/api/mcp/'` with trailing slash | Trailing slashes cause double-slash issues in route matching (e.g., `/api/mcp//sse`) | +| Disabling CORS | `cors: false` | Omitting the `cors` field entirely | Omitting `cors` applies permissive defaults (all origins allowed); set `false` explicitly to send no CORS headers | + +## Verification Checklist + +### Configuration + +- [ ] `http` block is present in the `@FrontMcp` decorator metadata +- [ ] Port value is a number (not a string) and falls within a valid range (0-65535) +- [ ] If `socketPath` is set, `port` is removed or commented out to avoid confusion +- [ ] `entryPath` does not have a trailing slash + +### CORS + +- [ ] If `credentials: true`, `origin` lists explicit allowed origins (not `true` or `*`) +- [ ] `maxAge` is set to a reasonable value for production (e.g., `86400` for 24 hours) +- [ ] Dynamic origin function handles `undefined` origin (non-browser requests) + +### Runtime + +- [ ] Server starts and binds to the expected port or socket path +- [ ] `curl -v -H "Origin: " ` returns correct `Access-Control-Allow-Origin` +- [ ] Preflight `OPTIONS` requests return `204` with expected CORS headers + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `EADDRINUSE` on startup | Another process is already using the configured port | Change the port, stop the other process, or use `port: 0` for a random available port | +| CORS errors in the browser console | Origin not included in the `cors.origin` list or `credentials: true` with wildcard origin | Add the frontend origin to the `origin` array and ensure credentials and origin settings are compatible | +| Unix socket file not created | Missing write permissions on the target directory or stale socket file from a previous run | Check directory permissions and remove the stale `.sock` file before restarting | +| Routes return 404 after setting `entryPath` | Client is still requesting the root path without the prefix | Update client base URL to include the entry path (e.g., `http://localhost:3001/api/mcp`) | +| Server binds but external clients cannot connect | Server bound to `localhost` or `127.0.0.1` inside a container | Set `host: '0.0.0.0'` or use Docker port mapping to expose the container port | + +## Reference + +- [HTTP Server Docs](https://docs.agentfront.dev/frontmcp/deployment/local-dev-server) +- Related skills: `configure-throttle`, `configure-transport`, `setup-redis`, `setup-project` diff --git a/libs/skills/catalog/frontmcp-config/references/configure-session.md b/libs/skills/catalog/frontmcp-config/references/configure-session.md new file mode 100644 index 00000000..ed5427b7 --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-session.md @@ -0,0 +1,205 @@ +# Configure Session Management + +This skill covers setting up session storage in FrontMCP. Sessions track authenticated user state, token storage, and request context across MCP interactions. + +## When to Use This Skill + +### Must Use + +- Deploying to production where sessions must survive process restarts (Redis or Vercel KV required) +- Running multiple server instances behind a load balancer that need shared session state +- Using Streamable HTTP transport where sessions must persist across reconnects + +### Recommended + +- Configuring session TTL to match your workload pattern (interactive, agent, CI/CD) +- Namespacing session keys with a unique `keyPrefix` when sharing a Redis instance across multiple servers +- Setting up Vercel KV for serverless deployments on the Vercel platform + +### Skip When + +- Running a single-instance local development server -- the default in-memory store is sufficient +- Using stdio transport only where session persistence is not needed +- Need to provision Redis itself rather than configure sessions -- use `setup-redis` first, then return here + +> **Decision:** Use this skill to choose and configure a session storage provider (memory, Redis, or Vercel KV) and tune TTL and key prefix settings; use `setup-redis` if Redis is not yet provisioned. + +## Storage Providers + +| Provider | Use Case | Persistence | Package Required | +| ----------- | ------------------- | ----------- | ---------------- | +| `memory` | Development/testing | None | None (default) | +| `redis` | Node.js production | Yes | `ioredis` | +| `vercel-kv` | Vercel deployments | Yes | `@vercel/kv` | + +Never use the memory store in production. Sessions are lost on process restart, which breaks authentication for all connected clients. + +## Redis (Production) + +Configure Redis session storage via the `@FrontMcp` decorator: + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ name: 'MyApp' }) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + redis: { + provider: 'redis', + host: process.env['REDIS_HOST'] ?? 'localhost', + port: Number(process.env['REDIS_PORT'] ?? 6379), + password: process.env['REDIS_PASSWORD'], + }, +}) +class MyServer {} +``` + +The SDK internally calls `createSessionStore()` to create a `RedisSessionStore`. The factory lazy-loads `ioredis` so it is not bundled when you use a different provider. + +## Vercel KV + +For Vercel deployments, use the `vercel-kv` provider. Credentials are read from environment variables set automatically by the Vercel platform: + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + redis: { provider: 'vercel-kv' }, +}) +class MyServer {} +``` + +Required environment variables (auto-injected when a KV store is linked to your Vercel project): + +| Variable | Description | +| ------------------- | ------------------------------ | +| `KV_REST_API_URL` | Vercel KV REST endpoint | +| `KV_REST_API_TOKEN` | Vercel KV authentication token | + +## Memory (Development Default) + +When no Redis or KV configuration is provided, the SDK falls back to an in-memory store. This is suitable only for development: + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + // No redis config -- defaults to memory +}) +class MyServer {} +``` + +## Key Prefix + +All persistent stores support a `keyPrefix` option that namespaces session keys. This is important when multiple FrontMCP servers share the same Redis instance: + +```typescript +@FrontMcp({ + info: { name: 'billing-server', version: '1.0.0' }, + apps: [MyApp], + redis: { + provider: 'redis', + host: 'shared-redis.internal', + port: 6379, + keyPrefix: 'billing-mcp:session:', + }, +}) +class BillingServer {} +``` + +Use a unique prefix per server to prevent session key collisions. + +## TTL Configuration + +The `defaultTtlMs` option controls how long sessions live before expiring: + +| Scenario | Recommended TTL | +| ---------------------------- | ----------------------- | +| Interactive user sessions | `3_600_000` (1 hour) | +| Long-running agent workflows | `86_400_000` (24 hours) | +| Short-lived CI/CD operations | `600_000` (10 minutes) | + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + redis: { + provider: 'redis', + host: 'localhost', + port: 6379, + defaultTtlMs: 86_400_000, // 24 hours for agent workflows + }, +}) +class MyServer {} +``` + +## Pub/Sub for Resource Subscriptions + +If your server uses resource subscriptions (clients subscribe to resource change notifications), you need a pub/sub channel. Vercel KV does not support pub/sub, so you must use Redis for the pub/sub channel even when using Vercel KV for sessions: + +```typescript +import { createSessionStore, createPubsubStore } from '@frontmcp/sdk/auth/session'; + +// Sessions in Vercel KV +const sessionStore = await createSessionStore({ + provider: 'vercel-kv', + url: process.env['KV_REST_API_URL'], + token: process.env['KV_REST_API_TOKEN'], +}); + +// Pub/sub requires Redis +const pubsubStore = createPubsubStore({ + provider: 'redis', + host: process.env['REDIS_HOST'] ?? 'localhost', + port: 6379, +}); +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ---------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Store construction | Use `createSessionStore()` factory function | `new RedisSessionStore(client)` direct construction | The factory handles lazy-loading, key prefix normalization, and provider detection automatically | +| Vercel KV creation | `const store = await createSessionStore({ provider: 'vercel-kv' })` | `const store = createSessionStore({ provider: 'vercel-kv' })` without `await` | The factory is async for Vercel KV; forgetting `await` uses the store before its connection is ready | +| Key prefix per server | `keyPrefix: 'billing-mcp:session:'` unique per server | Same `keyPrefix` across multiple servers sharing one Redis instance | Shared prefixes cause session key collisions; one server may read or overwrite another's sessions | +| Production storage | `redis: { provider: 'redis', host: '...' }` or `redis: { provider: 'vercel-kv' }` | Omitting redis config in production (falls back to memory) | Memory sessions vanish on restart; all connected clients must re-authenticate and in-flight workflows are lost | +| Pub/sub with Vercel KV | Separate `pubsub` config pointing to real Redis alongside `redis: { provider: 'vercel-kv' }` | Expecting Vercel KV to handle pub/sub | Vercel KV does not support pub/sub operations; a real Redis instance is required for resource subscriptions | + +## Verification Checklist + +### Configuration + +- [ ] `redis` block is present in the `@FrontMcp` decorator with a valid `provider` field (`'redis'` or `'vercel-kv'`) +- [ ] `keyPrefix` is unique per server when sharing a Redis instance +- [ ] `defaultTtlMs` matches the workload pattern (1 hour for interactive, 24 hours for agents, 10 minutes for CI/CD) + +### Vercel KV + +- [ ] `provider: 'vercel-kv'` is set in the `redis` config +- [ ] `KV_REST_API_URL` and `KV_REST_API_TOKEN` environment variables are present (auto-injected on Vercel) +- [ ] A separate `pubsub` config pointing to real Redis is provided if resource subscriptions are used + +### Runtime + +- [ ] Server starts without Redis connection errors in the logs +- [ ] `redis-cli keys "mcp:session:*"` shows session keys after an MCP request (for Redis provider) +- [ ] Sessions persist across server restarts (for Redis/Vercel KV providers) +- [ ] Sessions expire after the configured TTL + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Sessions lost after server restart | Using the default in-memory store in production | Configure `redis: { provider: 'redis' }` or `redis: { provider: 'vercel-kv' }` for persistence | +| `ECONNREFUSED` on startup | Redis is not running or host/port is incorrect | Start the Redis container (`docker compose up -d redis`) or verify connection details | +| Vercel KV `401 Unauthorized` | Missing or invalid KV tokens | Check `KV_REST_API_URL` and `KV_REST_API_TOKEN` in the Vercel dashboard and redeploy | +| Session key collisions between servers | Multiple servers share the same Redis instance and `keyPrefix` | Set a unique `keyPrefix` per server (e.g., `billing-mcp:session:`, `api-mcp:session:`) | +| Pub/sub not working with Vercel KV | Vercel KV does not support pub/sub operations | Add a separate `pubsub` config pointing to a real Redis instance | + +## Reference + +- [Session Storage Docs](https://docs.agentfront.dev/frontmcp/deployment/redis-setup) +- Related skills: `setup-redis`, `configure-auth`, `configure-transport`, `configure-elicitation` diff --git a/libs/skills/catalog/frontmcp-config/references/configure-throttle-guard-config.md b/libs/skills/catalog/frontmcp-config/references/configure-throttle-guard-config.md new file mode 100644 index 00000000..7e6918a8 --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-throttle-guard-config.md @@ -0,0 +1,68 @@ +# GuardConfig Full Reference + +## Complete Configuration + +```typescript +interface GuardConfig { + enabled: boolean; + + // Storage for distributed rate limiting + storage?: { + type: 'memory' | 'redis'; + redis?: RedisOptionsInput; + }; + + keyPrefix?: string; // default: 'mcp:guard:' + + // Server-wide limits + global?: RateLimitConfig; + globalConcurrency?: ConcurrencyConfig; + + // Default per-tool limits (overridden by tool-level config) + defaultRateLimit?: RateLimitConfig; + defaultConcurrency?: ConcurrencyConfig; + defaultTimeout?: TimeoutConfig; + + // IP-based access control + ipFilter?: IpFilterConfig; +} + +interface RateLimitConfig { + maxRequests: number; + windowMs?: number; // default: 60000 (1 minute) + partitionBy?: 'global' | 'ip' | 'session'; // default: 'global' +} + +interface ConcurrencyConfig { + maxConcurrent: number; + queueTimeoutMs?: number; // default: 0 (fail immediately) + partitionBy?: 'global' | 'ip' | 'session'; +} + +interface TimeoutConfig { + executeMs: number; +} + +interface IpFilterConfig { + allowList?: string[]; // IP addresses or CIDR ranges + denyList?: string[]; + defaultAction?: 'allow' | 'deny'; // default: 'allow' + trustProxy?: boolean; // default: false + trustedProxyDepth?: number; // default: 1 +} +``` + +## Partition Strategies + +- **`'global'`**: Single counter shared by all clients. Protects total server capacity. +- **`'ip'`**: Separate counter per client IP. Fair per-client limiting. +- **`'session'`**: Separate counter per MCP session. Fair per-session limiting. + +## Priority Order + +1. IP filter (allow/deny) — checked first +2. Global rate limit — checked second +3. Global concurrency — checked third +4. Per-tool rate limit — checked per tool +5. Per-tool concurrency — checked per tool +6. Per-tool timeout — enforced during execution diff --git a/libs/skills/catalog/frontmcp-config/references/configure-throttle.md b/libs/skills/catalog/frontmcp-config/references/configure-throttle.md new file mode 100644 index 00000000..5c29dfcb --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-throttle.md @@ -0,0 +1,229 @@ +# Configuring Throttle, Rate Limits, and IP Filtering + +Protect your FrontMCP server with rate limiting, concurrency control, execution timeouts, and IP filtering — at both server and per-tool levels. + +## When to Use This Skill + +### Must Use + +- Deploying a server to production where abuse protection and rate limiting are required +- Exposing expensive or destructive tools that need concurrency caps and execution timeouts +- Restricting access by IP address with allow/deny lists for compliance or security + +### Recommended + +- Enforcing per-session or per-IP request quotas to ensure fair resource distribution +- Adding global concurrency limits to prevent server overload under burst traffic +- Configuring distributed rate limiting across multiple server instances with Redis + +### Skip When + +- Running a local development server with stdio transport only -- throttle adds unnecessary overhead +- Only need CORS or port configuration without rate limiting -- use `configure-http` +- Need authentication or session management rather than rate limiting -- use `configure-session` or `configure-auth` + +> **Decision:** Use this skill when your server needs protection against abuse, rate limiting, concurrency control, IP filtering, or execution timeouts at either the server or per-tool level. + +## Server-Level Throttle (GuardConfig) + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + throttle: { + enabled: true, + + // Global rate limit (all requests combined) + global: { + maxRequests: 1000, + windowMs: 60000, // 1 minute window + partitionBy: 'global', // shared across all clients + }, + + // Global concurrency limit + globalConcurrency: { + maxConcurrent: 50, + partitionBy: 'global', + }, + + // Default limits for individual tools (applied unless tool overrides) + defaultRateLimit: { + maxRequests: 100, + windowMs: 60000, + }, + defaultConcurrency: { + maxConcurrent: 10, + }, + defaultTimeout: { + executeMs: 30000, // 30 second timeout + }, + + // IP filtering + ipFilter: { + allowList: ['10.0.0.0/8', '172.16.0.0/12'], // CIDR ranges + denyList: ['192.168.1.100'], + defaultAction: 'allow', // 'allow' | 'deny' + trustProxy: true, // trust X-Forwarded-For + trustedProxyDepth: 1, // proxy depth to trust + }, + }, +}) +class Server {} +``` + +## Per-Tool Rate Limiting + +Override server defaults on individual tools: + +```typescript +@Tool({ + name: 'expensive_query', + description: 'Run an expensive database query', + inputSchema: { + query: z.string(), + }, + outputSchema: { rows: z.array(z.record(z.unknown())) }, + + // Per-tool limits + rateLimit: { + maxRequests: 10, + windowMs: 60000, + partitionBy: 'session', // per-session rate limit + }, + concurrency: { + maxConcurrent: 3, + queueTimeoutMs: 5000, // wait up to 5s for a slot + partitionBy: 'session', + }, + timeout: { + executeMs: 60000, // 60 second timeout for this tool + }, +}) +class ExpensiveQueryTool extends ToolContext { + async execute(input: { query: string }) { + const db = this.get(DB_TOKEN); + return { rows: await db.query(input.query) }; + } +} +``` + +## Configuration Types + +### RateLimitConfig + +| Field | Type | Default | Description | +| ------------- | ------------------------------- | ---------- | ------------------------- | +| `maxRequests` | `number` | — | Max requests per window | +| `windowMs` | `number` | `60000` | Window duration in ms | +| `partitionBy` | `'global' \| 'ip' \| 'session'` | `'global'` | How to partition counters | + +### ConcurrencyConfig + +| Field | Type | Default | Description | +| ---------------- | ------------------------------- | ---------- | -------------------------------------------------- | +| `maxConcurrent` | `number` | — | Max simultaneous executions | +| `queueTimeoutMs` | `number` | `0` | How long to wait for a slot (0 = fail immediately) | +| `partitionBy` | `'global' \| 'ip' \| 'session'` | `'global'` | How to partition counters | + +### TimeoutConfig + +| Field | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------ | +| `executeMs` | `number` | — | Max execution time in ms | + +### IpFilterConfig + +| Field | Type | Default | Description | +| ------------------- | ------------------- | --------- | ----------------------------------- | +| `allowList` | `string[]` | — | Allowed IPs or CIDR ranges | +| `denyList` | `string[]` | — | Blocked IPs or CIDR ranges | +| `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list | +| `trustProxy` | `boolean` | `false` | Trust X-Forwarded-For header | +| `trustedProxyDepth` | `number` | `1` | How many proxy hops to trust | + +## Partition Strategies + +- **`'global'`** — Single shared counter for all clients. Use for global capacity limits. +- **`'ip'`** — Separate counter per client IP. Use for per-client rate limiting. +- **`'session'`** — Separate counter per MCP session. Use for per-session fairness. + +## Distributed Rate Limiting + +For multi-instance deployments, configure Redis storage in the guard: + +```typescript +throttle: { + enabled: true, + storage: { + type: 'redis', + redis: { config: { host: 'redis.internal', port: 6379 } }, + }, + global: { maxRequests: 1000, windowMs: 60000 }, +} +``` + +## Verification + +```bash +# Start server +frontmcp dev + +# Test rate limiting (send 101 requests rapidly) +for i in $(seq 1 101); do + curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3001/ \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +done +# Should see 429 responses after limit is exceeded +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Per-tool override | Set `rateLimit` on the `@Tool` decorator to override server defaults | Duplicating the full server-level `throttle` config inside each tool | Per-tool config merges with server defaults; only specify the fields you want to override | +| Partition strategy | Use `partitionBy: 'session'` for per-user fairness on shared tools | Using `partitionBy: 'global'` for all limits | Global partitioning means one abusive client can exhaust the quota for everyone | +| Distributed rate limiting | Configure `storage: { type: 'redis', redis: { config: { host, port } } }` in the throttle block for multi-instance deployments | Relying on in-memory counters with multiple server instances | In-memory counters are per-process; each instance tracks limits independently, allowing N times the intended rate | +| IP filter ordering | Set `defaultAction: 'deny'` with an explicit `allowList` for strict environments | Setting `defaultAction: 'allow'` with only a `denyList` | A deny-by-default posture is safer; new unknown IPs are blocked until explicitly allowed | +| Concurrency queue timeout | Set `queueTimeoutMs` on concurrency config to queue excess requests briefly | Setting `queueTimeoutMs: 0` on expensive tools | Zero timeout immediately rejects excess requests instead of briefly queuing them, causing unnecessary failures during short bursts | + +## Verification Checklist + +### Configuration + +- [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator +- [ ] `global.maxRequests` and `global.windowMs` are set to reasonable production values +- [ ] `defaultTimeout.executeMs` is configured to prevent runaway tool executions +- [ ] IP filter `defaultAction` matches your security posture (`allow` for open, `deny` for restricted) + +### Per-Tool + +- [ ] Expensive or destructive tools have explicit `rateLimit` and `concurrency` overrides +- [ ] `partitionBy` is set to `'session'` or `'ip'` for tools that need per-client fairness +- [ ] `queueTimeoutMs` is set on concurrency-limited tools to handle brief bursts + +### Distributed + +- [ ] Redis storage is configured in the throttle block for multi-instance deployments +- [ ] Redis connection is verified before deploying (see `setup-redis`) + +### Runtime + +- [ ] Sending requests beyond the rate limit returns HTTP 429 +- [ ] Blocked IPs receive HTTP 403 +- [ ] Tool executions that exceed `executeMs` are terminated and return a timeout error + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters | +| All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries | Add the allowed IP ranges to `allowList` or change `defaultAction` to `'allow'` | +| Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override | +| `X-Forwarded-For` header ignored | `ipFilter.trustProxy` not enabled or `trustedProxyDepth` too low | Set `trustProxy: true` and adjust `trustedProxyDepth` to match your proxy chain | +| Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval | + +## Reference + +- [Guard Configuration Docs](https://docs.agentfront.dev/frontmcp/servers/guard) +- Related skills: `configure-http`, `configure-transport`, `setup-redis`, `configure-auth` diff --git a/libs/skills/catalog/frontmcp-config/references/configure-transport-protocol-presets.md b/libs/skills/catalog/frontmcp-config/references/configure-transport-protocol-presets.md new file mode 100644 index 00000000..0722cdbf --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-transport-protocol-presets.md @@ -0,0 +1,57 @@ +# Transport Protocol Presets Reference + +## Preset Configurations + +### `'legacy'` (Default) + +Maximum compatibility with all MCP clients including older versions. + +```typescript +{ sse: true, streamable: true, json: false, stateless: false, legacy: true, strictSession: true } +``` + +### `'modern'` + +Modern protocol only. Drops legacy SSE support. + +```typescript +{ sse: true, streamable: true, json: false, stateless: false, legacy: false, strictSession: true } +``` + +### `'stateless-api'` + +No sessions. Pure request/response for serverless. + +```typescript +{ sse: false, streamable: false, json: false, stateless: true, legacy: false, strictSession: false } +``` + +### `'full'` + +All protocols enabled. Maximum flexibility. + +```typescript +{ sse: true, streamable: true, json: true, stateless: true, legacy: true, strictSession: false } +``` + +## Protocol Fields + +| Field | Description | Effect when `true` | +| --------------- | -------------------- | ----------------------------------------------------- | +| `sse` | SSE endpoint | Enables `/sse` endpoint for server-sent events | +| `streamable` | Streamable HTTP POST | Enables streaming responses via HTTP POST | +| `json` | JSON-only responses | Returns complete JSON without streaming | +| `stateless` | Stateless HTTP | No session management, each request standalone | +| `legacy` | Legacy SSE transport | Backwards-compatible SSE for older clients | +| `strictSession` | Require session ID | Streamable HTTP POST requires `mcp-session-id` header | + +## Deployment Recommendations + +| Deployment | Preset | Why | +| ------------------------- | ------------------------------ | ----------------------------------------- | +| Node.js (single instance) | `'legacy'` | Max compatibility, simple setup | +| Node.js (load balanced) | `'modern'` + Redis persistence | Modern protocol with distributed sessions | +| Vercel | `'stateless-api'` | No persistent connections allowed | +| AWS Lambda | `'stateless-api'` | Stateless execution model | +| Cloudflare Workers | `'stateless-api'` | Stateless edge runtime | +| Development | `'full'` | Test all protocols | diff --git a/libs/skills/catalog/frontmcp-config/references/configure-transport.md b/libs/skills/catalog/frontmcp-config/references/configure-transport.md new file mode 100644 index 00000000..a64da85a --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/configure-transport.md @@ -0,0 +1,195 @@ +# Configuring Transport + +Configure how clients connect to your FrontMCP server — SSE, Streamable HTTP, stateless API, or a combination. + +## When to Use This Skill + +### Must Use + +- Setting up a new FrontMCP server and need to decide on a transport protocol (SSE, Streamable HTTP, or stateless) +- Deploying to serverless targets (Vercel, Lambda, Cloudflare) that require stateless transport mode +- Running multiple server instances behind a load balancer that require distributed sessions via Redis + +### Recommended + +- Migrating an existing server from legacy SSE to modern Streamable HTTP +- Enabling SSE event resumability so clients can reconnect after network interruptions +- Fine-tuning protocol flags beyond what the built-in presets provide + +### Skip When + +- You are configuring authentication or session tokens (use `configure-auth` instead) +- You need to set up plugin middleware without changing the transport layer (use `create-plugin` reference instead) + +> **Decision:** Use this skill whenever you need to choose, combine, or customize the protocol(s) your MCP server exposes to clients. + +## TransportOptionsInput + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + transport: { + sessionMode: 'stateful', // 'stateful' | 'stateless' + protocol: 'legacy', // preset or custom ProtocolConfig + persistence: { + // false to disable + redis: { provider: 'redis', host: 'localhost', port: 6379 }, + defaultTtlMs: 3600000, + }, + distributedMode: 'auto', // boolean | 'auto' + eventStore: { + enabled: true, + provider: 'redis', // 'memory' | 'redis' + maxEvents: 10000, + ttlMs: 300000, + }, + }, +}) +class Server {} +``` + +## Protocol Presets + +Choose a preset that matches your deployment: + +| Preset | SSE | Streamable HTTP | JSON | Stateless | Legacy SSE | Strict Session | +| -------------------- | --- | --------------- | ---- | --------- | ---------- | -------------- | +| `'legacy'` (default) | Yes | Yes | No | No | Yes | Yes | +| `'modern'` | Yes | Yes | No | No | No | Yes | +| `'stateless-api'` | No | No | No | Yes | No | No | +| `'full'` | Yes | Yes | Yes | Yes | Yes | No | + +### When to Use Each + +- **`'legacy'`** — Default. Maximum compatibility with all MCP clients (Claude Desktop, etc.). Best for Node.js deployments. +- **`'modern'`** — Drop legacy SSE support. Use when all clients support modern MCP protocol. +- **`'stateless-api'`** — No sessions, pure request/response. Use for **Vercel**, **Lambda**, and other serverless targets. +- **`'full'`** — All protocols enabled. Use for development or when you need every transport option. + +### Custom Protocol Config + +Override individual protocol flags: + +```typescript +transport: { + protocol: { + sse: true, // SSE listener endpoint + streamable: true, // Streamable HTTP POST + json: false, // JSON-only responses (no streaming) + stateless: false, // Stateless HTTP (no sessions) + legacy: false, // Legacy SSE transport + strictSession: true, // Require session ID for streamable HTTP + }, +} +``` + +## Distributed Sessions + +For multi-instance deployments (load balanced), enable persistence with Redis: + +```typescript +transport: { + distributedMode: true, + persistence: { + redis: { provider: 'redis', host: 'redis.internal', port: 6379 }, + defaultTtlMs: 3600000, // 1 hour session TTL + }, +} +``` + +- `distributedMode: 'auto'` — auto-detect based on whether Redis is configured +- `distributedMode: true` — force distributed mode (requires Redis) +- `distributedMode: false` — single-instance mode (in-memory sessions) + +## Event Store (SSE Resumability) + +Enable event store so clients can resume SSE connections after disconnects: + +```typescript +transport: { + eventStore: { + enabled: true, + provider: 'redis', // 'memory' for single instance, 'redis' for distributed + maxEvents: 10000, // max events to store + ttlMs: 300000, // 5 minute TTL + redis: { provider: 'redis', host: 'localhost' }, + }, +} +``` + +## Target-Specific Recommendations + +| Target | Recommended Preset | Persistence | Event Store | +| ------------------------ | ------------------ | ----------- | ----------- | +| Node.js (single) | `'legacy'` | `false` | Memory | +| Node.js (multi-instance) | `'modern'` | Redis | Redis | +| Vercel | `'stateless-api'` | `false` | Disabled | +| Lambda | `'stateless-api'` | `false` | Disabled | +| Cloudflare | `'stateless-api'` | `false` | Disabled | + +## Verification + +```bash +# Start server and test SSE +frontmcp dev + +# Test SSE endpoint +curl -N http://localhost:3001/sse + +# Test streamable HTTP +curl -X POST http://localhost:3001/ -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Choosing a preset | `protocol: 'modern'` | `protocol: { sse: true, streamable: true, legacy: false }` | Use a preset when it matches your needs; custom config is for overrides only | +| Serverless transport | `protocol: 'stateless-api'` with `sessionMode: 'stateless'` | `protocol: 'legacy'` on Lambda | Legacy preset creates sessions that serverless cannot maintain between invocations | +| Distributed sessions | `distributedMode: true` with Redis `persistence` configured | `distributedMode: true` without Redis | Distributed mode requires Redis; omitting it causes a startup error | +| Event store provider | `provider: 'redis'` for multi-instance, `provider: 'memory'` for single instance | `provider: 'memory'` behind a load balancer | In-memory event store is not shared across instances, breaking SSE resumability | +| Session TTL | Set `defaultTtlMs` to match your expected session duration | Omitting `defaultTtlMs` when using Redis persistence | Missing TTL can cause sessions to accumulate indefinitely in Redis | + +## Verification Checklist + +### Transport Protocol + +- [ ] Correct preset is chosen for the deployment target (see Target-Specific Recommendations table) +- [ ] Custom protocol flags, if used, do not conflict with the selected `sessionMode` +- [ ] Legacy SSE is disabled when all clients support modern MCP protocol + +### Session and Persistence + +- [ ] `sessionMode` is `'stateless'` for serverless deployments +- [ ] `distributedMode` is enabled and Redis is configured for multi-instance deployments +- [ ] `defaultTtlMs` is set to a reasonable value when persistence is enabled + +### Event Store + +- [ ] Event store provider matches the deployment topology (memory for single, Redis for distributed) +- [ ] `maxEvents` and `ttlMs` are tuned for expected traffic volume +- [ ] Event store is disabled for stateless-api deployments + +### Runtime Validation + +- [ ] Server starts without transport-related errors +- [ ] SSE endpoint (`/sse`) responds with `text/event-stream` when SSE is enabled +- [ ] Streamable HTTP endpoint (`/`) accepts JSON-RPC POST requests when streamable is enabled +- [ ] Clients can reconnect and resume SSE streams when event store is enabled + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Server rejects SSE connections | SSE is disabled in the protocol config or preset | Switch to `'legacy'`, `'modern'`, or `'full'` preset, or set `sse: true` in custom config | +| `distributedMode` startup error | Redis persistence is not configured | Add a `persistence.redis` block with valid connection details | +| Clients lose state after reconnect | Event store is disabled or using in-memory provider behind a load balancer | Enable event store with `provider: 'redis'` for distributed deployments | +| Serverless function times out on SSE | Using a stateful preset on a serverless target | Switch to `'stateless-api'` preset and set `sessionMode: 'stateless'` | +| Session not found after server restart | In-memory sessions do not survive restarts | Enable Redis persistence with `distributedMode: true` | +| Streamable HTTP returns 404 | Streamable HTTP is not enabled in the current preset | Use `'modern'`, `'legacy'`, or `'full'` preset, or set `streamable: true` in custom config | + +## Reference + +- **Docs:** [Runtime Modes and Transport Configuration](https://docs.agentfront.dev/frontmcp/deployment/runtime-modes) +- **Related skills:** `configure-auth`, `create-plugin` diff --git a/libs/skills/catalog/frontmcp-config/references/setup-redis.md b/libs/skills/catalog/frontmcp-config/references/setup-redis.md new file mode 100644 index 00000000..ed52feea --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/setup-redis.md @@ -0,0 +1,4 @@ +# Redis Setup Reference + +> This reference is maintained in `frontmcp-setup/references/setup-redis.md`. +> See that file for the full Redis configuration guide including connection options, Vercel KV setup, Docker Compose examples, and troubleshooting. diff --git a/libs/skills/catalog/frontmcp-config/references/setup-sqlite.md b/libs/skills/catalog/frontmcp-config/references/setup-sqlite.md new file mode 100644 index 00000000..0ef88a30 --- /dev/null +++ b/libs/skills/catalog/frontmcp-config/references/setup-sqlite.md @@ -0,0 +1,4 @@ +# SQLite Setup Reference + +> This reference is maintained in `frontmcp-setup/references/setup-sqlite.md`. +> See that file for the full SQLite configuration guide including WAL mode, encryption, daemon mode, and troubleshooting. diff --git a/libs/skills/catalog/frontmcp-deployment/SKILL.md b/libs/skills/catalog/frontmcp-deployment/SKILL.md new file mode 100644 index 00000000..36795e5a --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/SKILL.md @@ -0,0 +1,124 @@ +--- +name: frontmcp-deployment +description: "Domain router for shipping MCP servers \u2014 deploy to Node, Vercel, Lambda, Cloudflare, or build for CLI, browser, and SDK. Use when choosing a deployment target or build format." +tags: [router, deployment, node, vercel, lambda, cloudflare, cli, browser, sdk, guide] +priority: 10 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/deployment/overview +--- + +# FrontMCP Deployment Router + +Entry point for deploying and building FrontMCP servers. This skill helps you choose the right deployment target or build format based on your infrastructure requirements. + +## When to Use This Skill + +### Must Use + +- Choosing between deployment targets (Node vs Vercel vs Lambda vs Cloudflare) for a new project +- Deciding on a build format (server vs CLI vs browser vs SDK) for distribution +- Planning infrastructure and need to understand trade-offs between deployment options + +### Recommended + +- Comparing serverless platforms for cost, cold-start, and feature support +- Understanding which transport protocol and storage provider each target requires +- Migrating from one deployment target to another + +### Skip When + +- You already know your deployment target (go directly to `deploy-to-node`, `deploy-to-vercel`, etc.) +- You need to configure server settings, not deploy (see `frontmcp-config`) +- You need to build components, not ship them (see `frontmcp-development`) + +> **Decision:** Use this skill when you need to figure out WHERE to deploy. Use the specific skill when you already know. + +## Prerequisites + +- A working FrontMCP server with at least one `@App` and one `@Tool` (see `frontmcp-development`) +- Server configuration completed (see `frontmcp-config`) +- Tests passing locally (see `frontmcp-testing`) + +## Steps + +1. Review the Scenario Routing Table and Target Comparison below to choose a deployment target +2. Run `frontmcp build --target ` to produce the build output +3. Follow the specific deployment skill (e.g., `deploy-to-node`, `deploy-to-vercel`) for platform instructions +4. Verify with the Post-Deployment checklist at the end of this skill + +## Scenario Routing Table + +| Scenario | Skill | Description | +| ------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | +| Long-running server on VPS, Docker, or bare metal | `deploy-to-node` | Node.js with stdio or HTTP transport, PM2/Docker for process management | +| Serverless with zero config and Vercel KV | `deploy-to-vercel` | Vercel Functions with Streamable HTTP, Vercel KV for storage | +| AWS serverless with API Gateway | `deploy-to-lambda` | Lambda + API Gateway with Streamable HTTP, DynamoDB or ElastiCache | +| Edge computing with global distribution | `deploy-to-cloudflare` | Cloudflare Workers with KV or Durable Objects for storage | +| Standalone executable binary for distribution | `build-for-cli` | Single-binary CLI with stdio transport, embedded storage | +| Run MCP in a web browser | `build-for-browser` | Browser-compatible bundle with in-memory transport | +| Embed MCP into an existing Node.js application | `build-for-sdk` | Library build for programmatic usage without standalone server | + +## Target Comparison + +| Target | Transport | Storage | Cold Start | Stateful | Best For | +| ---------- | --------------------------- | --------------------- | ---------- | -------- | -------------------------------- | +| Node | stdio, SSE, Streamable HTTP | Redis, SQLite, memory | None | Yes | Full-featured production servers | +| Vercel | Streamable HTTP (stateless) | Vercel KV | ~250ms | No | Rapid deployment, hobby/startup | +| Lambda | Streamable HTTP (stateless) | DynamoDB, ElastiCache | ~500ms | No | AWS ecosystem, event-driven | +| Cloudflare | Streamable HTTP (stateless) | KV, Durable Objects | ~5ms | Limited | Edge-first, global latency | +| CLI | stdio | SQLite, memory | None | Yes | Desktop tools, local agents | +| Browser | In-memory | memory | None | Yes | Client-side AI, demos | +| SDK | Programmatic | Configurable | None | Yes | Embedding in existing apps | + +> **Note on storage:** The FrontMCP SDK's `StorageProvider` type supports `'redis'` and `'vercel-kv'` as built-in providers. References to DynamoDB, Cloudflare KV, D1, and Durable Objects in the table above refer to platform-native storage that you configure outside the SDK (e.g., via AWS SDK, Cloudflare bindings). The SDK does not provide a built-in adapter for these — use them directly in your tools/providers. + +## Cross-Cutting Patterns + +| Pattern | Rule | +| --------------------- | -------------------------------------------------------------------------------------------------------- | +| Transport selection | Stateful servers (Node, CLI) can use stdio or SSE; serverless must use Streamable HTTP (stateless) | +| Storage mapping | Node: Redis or SQLite; Vercel: Vercel KV; Lambda: DynamoDB; Cloudflare: KV; CLI: SQLite; Browser: memory | +| Environment variables | Never hardcode secrets; use `.env` locally, platform secrets in production | +| Build command | All targets: `frontmcp build --target ` produces optimized output | +| Entry point | All targets require `export default` of the `@FrontMcp` class from `main.ts` | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | ------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------- | +| Target selection | Choose based on infrastructure constraints | Choose based on familiarity | Each target has different transport, storage, and cold-start characteristics | +| Serverless storage | Use platform-native storage (Vercel KV, DynamoDB) | Use Redis on serverless | Platform-native storage avoids VPC/connection overhead on cold starts | +| Environment config | Platform secrets (Vercel env, AWS SSM) | `.env` files in production | Platform secrets are encrypted, rotatable, and not committed to git | +| Build verification | Run `frontmcp build --target ` before deploying | Deploy source code directly | Build step validates config, bundles dependencies, and optimizes output | + +## Verification Checklist + +### Pre-Deployment + +- [ ] `frontmcp build --target ` completes without errors +- [ ] Environment variables configured for the target platform +- [ ] Storage provider configured and accessible (Redis, KV, DynamoDB, etc.) +- [ ] Transport protocol matches target requirements (stateless for serverless) + +### Post-Deployment + +- [ ] Health check endpoint responds +- [ ] `tools/list` returns expected tools +- [ ] Tool execution works end-to-end +- [ ] Storage persistence verified (create, read, restart, read again) + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | +| Cold start timeout on serverless | Bundle too large or heavy initialization | Lazy-load providers; reduce bundle with tree shaking; increase function timeout | +| Session lost between requests | Using memory storage on stateless serverless | Switch to platform-native storage (Vercel KV, DynamoDB, etc.) | +| CORS errors on browser/web clients | HTTP CORS not configured | Add CORS config via `configure-http` skill | +| Build fails with missing module | Node-only module in browser/edge build | Use conditional imports or `@frontmcp/utils` cross-platform utilities | + +## Reference + +- [Deployment Overview](https://docs.agentfront.dev/frontmcp/deployment/overview) +- Related skills: `deploy-to-node`, `deploy-to-vercel`, `deploy-to-lambda`, `deploy-to-cloudflare`, `build-for-cli`, `build-for-browser`, `build-for-sdk`, `configure-transport` diff --git a/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md b/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md new file mode 100644 index 00000000..5d518ec6 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md @@ -0,0 +1,138 @@ +# Building for Browser + +Build your FrontMCP server or client for browser environments. + +## When to Use This Skill + +### Must Use + +- Building a browser-compatible MCP client or tool interface for a web application +- Embedding MCP tools in a React, Vue, or other frontend framework using `@frontmcp/react` +- Creating a client-side bundle that connects to a remote MCP server + +### Recommended + +- Prototyping MCP tool UIs in the browser before building a full backend +- Shipping a web-based admin dashboard that lists and invokes MCP tools +- Building a PWA or single-page app that consumes MCP resources + +### Skip When + +- Running MCP tools on a Node.js server -- use `--target node` or `build-for-cli` +- Embedding MCP in an existing Node.js app without HTTP -- use `build-for-sdk` +- Deploying to Cloudflare Workers or other edge runtimes -- use `deploy-to-cloudflare` + +> **Decision:** Choose this skill when the MCP consumer runs in a browser; use server-side build targets for Node.js environments. + +## Build Command + +```bash +frontmcp build --target browser +``` + +### Options + +```bash +frontmcp build --target browser -o ./dist/browser # Custom output directory +frontmcp build --target browser -e ./src/client.ts # Custom entry file +``` + +## Browser Limitations + +Not all FrontMCP features are available in browser environments: + +| Feature | Browser Support | Notes | +| --------------------------- | --------------- | ----------------------------------------- | +| Tools (client-side) | Yes | Can define and run tools | +| Resources | Yes | Read-only access | +| Prompts | Yes | Full support | +| Redis | No | Use in-memory or connect to server | +| SQLite | No | No filesystem access | +| File system utilities | No | `@frontmcp/utils` fs ops throw in browser | +| Crypto (`@frontmcp/utils`) | Yes | Uses WebCrypto API | +| Direct client (`connect()`) | Yes | In-memory connection | + +## Usage with @frontmcp/react + +The browser build is commonly paired with `@frontmcp/react` for React applications: + +```typescript +import { FrontMcpProvider, useTools } from '@frontmcp/react'; + +function App() { + return ( + + + + ); +} + +function ToolUI() { + const { tools, callTool } = useTools(); + // Use tools in your React components +} +``` + +## Browser vs Node vs SDK Target + +| Aspect | `--target browser` | `--target node` | `--target sdk` | +| ----------- | ------------------ | ----------------- | ------------------- | +| Runtime | Browser | Node.js server | Node.js library | +| Output | Browser bundle | Server executable | CJS + ESM + types | +| HTTP server | No | Yes | No (`serve: false`) | +| Use case | Frontend apps | Standalone server | Embed in Node apps | + +## Verification + +```bash +# Build +frontmcp build --target browser + +# Check output +ls dist/browser/ +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------- | ---------------------------------------- | --------------------------------- | ------------------------------------------ | +| Crypto usage | `@frontmcp/utils` (uses WebCrypto) | `node:crypto` | `node:crypto` is not available in browsers | +| Storage | In-memory stores or remote API | SQLite / Redis directly | No filesystem or native TCP in browsers | +| File system ops | Avoid `@frontmcp/utils` fs functions | `readFile()`, `writeFile()` | fs utilities throw in browser environments | +| Entry file | Separate browser entry (`src/client.ts`) | Reusing server entry point | Server entry may import Node-only modules | +| Server connection | `FrontMcpProvider` with `serverUrl` | Direct `connect()` with localhost | Browser needs a remote URL, not localhost | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target browser` completes without errors +- [ ] Output directory contains browser-compatible JS bundle +- [ ] No Node.js-only modules are included in the bundle + +**Runtime** + +- [ ] Bundle loads in the browser without console errors +- [ ] MCP tools are listed and callable from the frontend +- [ ] WebCrypto-based operations (auth, PKCE) work correctly + +**Integration** + +- [ ] `@frontmcp/react` provider connects to the remote MCP server +- [ ] Tool invocations return expected results in the UI +- [ ] Resources and prompts render correctly in browser components + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------- | ----------------------------------------- | ---------------------------------------------------------------- | +| `Module not found: fs` | Node.js module imported in browser bundle | Use a separate browser entry point that avoids Node-only imports | +| `crypto is not defined` | Using `node:crypto` instead of WebCrypto | Switch to `@frontmcp/utils` crypto functions | +| CORS errors on tool calls | MCP server missing CORS headers | Configure CORS middleware on the MCP server | +| Bundle too large | All server-side code included | Use `--target browser` and a dedicated client entry file | +| `@frontmcp/utils` fs throws | File system ops called in browser | Remove fs calls; use API endpoints or in-memory alternatives | + +## Reference + +- **Docs:** +- **Related skills:** `build-for-sdk`, `build-for-cli`, `deploy-to-cloudflare` diff --git a/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md b/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md new file mode 100644 index 00000000..1a70b3fe --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md @@ -0,0 +1,138 @@ +# Building a CLI Binary + +Build your FrontMCP server as a distributable CLI binary using Node.js Single Executable Applications (SEA) or as a bundled JS file. + +## When to Use This Skill + +### Must Use + +- Distributing your MCP server as a standalone executable that runs without Node.js +- Creating a CLI tool installable via package managers (Homebrew, apt, etc.) +- Producing a self-contained binary for air-gapped or dependency-free deployment + +### Recommended + +- Shipping an MCP-powered developer tool to end users who may not have Node.js +- Building platform-specific binaries for CI/CD artifact pipelines +- Creating a single-file JS bundle for lightweight Node.js execution + +### Skip When + +- Deploying to a server environment with Node.js available -- use `--target node` +- Embedding tools in an existing Node.js application -- use `build-for-sdk` +- Targeting browser environments -- use `build-for-browser` + +> **Decision:** Choose this skill when your goal is a distributable binary or bundled JS file; use other build targets for server or library deployments. + +## Build Commands + +### Native Binary (SEA) + +```bash +frontmcp build --target cli +``` + +Produces a Node.js Single Executable Application — a single binary embedding your server code and the Node.js runtime. + +### JS Bundle Only + +```bash +frontmcp build --target cli --js +``` + +Produces a bundled JS file without the native binary wrapper. Run with `node dist/server.js`. + +### Options + +```bash +frontmcp build --target cli -o ./build # Custom output directory +frontmcp build --target cli -e ./src/main.ts # Custom entry file +frontmcp build --target cli --js # JS bundle only (no SEA) +``` + +## Requirements + +- **Node.js 22+** required for SEA support +- The entry file must export or instantiate a `@FrontMcp` decorated class +- SEA binaries are platform-specific (build on macOS for macOS, Linux for Linux) + +## CLI Target vs Node Target + +| Aspect | `--target cli` | `--target node` | +| -------- | ---------------------------------- | ------------------------------ | +| Output | Single binary or JS bundle | JS files for server deployment | +| Runtime | Embedded Node.js (SEA) or external | Requires Node.js installed | +| Use case | Distribution to end users | Server deployment (Docker, VM) | +| Includes | Bundled dependencies | External node_modules | + +## Server Configuration for CLI Mode + +When building for CLI distribution, configure your server for local/stdin transport: + +```typescript +@FrontMcp({ + info: { name: 'my-cli-tool', version: '1.0.0' }, + apps: [MyApp], + http: { socketPath: '/tmp/my-tool.sock' }, // Unix socket instead of TCP + sqlite: { path: '~/.my-tool/data.db' }, // Local storage +}) +class MyCLIServer {} +``` + +## Verification + +```bash +# Build +frontmcp build --target cli + +# Test the binary +./dist/my-server --help + +# Or test JS bundle +node dist/my-server.cjs.js +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------- | --------------------------------------------------- | -------------------------------- | ----------------------------------------------------------- | +| Node.js version | Node.js 22+ for SEA builds | Node.js 18 or 20 | SEA support requires Node.js 22+ | +| Entry file | Export or instantiate a `@FrontMcp` decorated class | Export a plain function | The build expects a FrontMcp entry point | +| Transport for CLI | `socketPath` or stdin/stdout | TCP port binding | CLI tools run locally; ports may conflict | +| Cross-platform binary | Build on each target OS separately | Build on macOS and ship to Linux | SEA binaries are platform-specific | +| JS-only bundle | `frontmcp build --target cli --js` | `frontmcp build --target node` | `--target node` assumes server deployment with node_modules | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target cli` completes without errors +- [ ] Output directory contains the expected binary or `.cjs.js` file +- [ ] Binary file size is reasonable (no unexpected bloat) + +**Runtime** + +- [ ] Binary runs without Node.js installed on a clean machine +- [ ] `--help` flag prints usage information +- [ ] JS bundle runs correctly with `node dist/my-server.cjs.js` + +**Distribution** + +- [ ] Binary is tested on the target platform (macOS, Linux, Windows) +- [ ] Exit codes are correct (0 for success, non-zero for errors) +- [ ] No hard-coded absolute paths in the bundled output + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------- | ------------------------------------------- | ----------------------------------------------------------- | +| SEA build fails | Node.js version below 22 | Upgrade to Node.js 22+ | +| Binary crashes on startup | Missing `@FrontMcp` decorated entry | Ensure entry file exports or instantiates a decorated class | +| Binary too large | All dependencies bundled including dev deps | Review dependencies and remove unused packages from bundle | +| Permission denied on binary | Missing execute permission | Run `chmod +x dist/my-server` | +| Binary fails on different OS | SEA binaries are platform-specific | Build on the target OS or use CI matrix builds | + +## Reference + +- **Docs:** +- **Related skills:** `build-for-sdk`, `build-for-browser`, `deploy-to-cloudflare` diff --git a/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md b/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md new file mode 100644 index 00000000..2702fd66 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md @@ -0,0 +1,259 @@ +# Building as an SDK Library + +Build your FrontMCP server as an embeddable library that runs without an HTTP server. Use `create()` for flat-config setup or `connect()` for platform-specific tool formatting (OpenAI, Claude, LangChain, Vercel AI). + +## When to Use This Skill + +### Must Use + +- Embedding MCP tools in an existing Node.js application without starting an HTTP server +- Distributing your MCP server as an npm package with CJS + ESM + TypeScript declarations +- Connecting tools to LLM platforms (OpenAI, Claude, LangChain, Vercel AI) via `connect*()` functions + +### Recommended + +- Running MCP tools in-memory for low-latency, zero-network-overhead execution +- Building a shared tool library consumed by multiple services in a monorepo +- Testing MCP tools programmatically in integration test suites + +### Skip When + +- Deploying a standalone MCP server that listens on a port -- use `--target node` or `build-for-cli` +- Building a browser-based MCP client -- use `build-for-browser` +- Deploying to Cloudflare Workers -- use `deploy-to-cloudflare` + +> **Decision:** Choose this skill when you need MCP tools as a library or programmatic API; use other targets for standalone servers or browser clients. + +## Build Command + +```bash +frontmcp build --target sdk +``` + +Produces dual-format output: + +- `{name}.cjs.js` — CommonJS format +- `{name}.esm.mjs` — ES Module format +- `*.d.ts` — TypeScript declarations + +All `@frontmcp/*` dependencies are marked as external (not bundled). + +## Disable HTTP Server + +Set `serve: false` in your `@FrontMcp` decorator to prevent the HTTP listener from starting: + +```typescript +@FrontMcp({ + info: { name: 'my-sdk', version: '1.0.0' }, + apps: [MyApp], + serve: false, // No HTTP server — library mode only +}) +class MySDK {} +``` + +## Programmatic Usage with `create()` + +The `create()` factory spins up a server from a flat config object — no decorators or classes needed: + +```typescript +import { create } from '@frontmcp/sdk'; +import { z } from 'zod'; + +const server = await create({ + info: { name: 'my-service', version: '1.0.0' }, + tools: [ + tool({ + name: 'calculate', + description: 'Perform calculation', + inputSchema: { expression: z.string() }, + outputSchema: { result: z.number() }, + })((input) => ({ result: eval(input.expression) })), + ], + cacheKey: 'my-service', // Reuse same instance on repeated calls +}); + +// Call tools directly +const result = await server.callTool('calculate', { expression: '2 + 2' }); + +// List available tools +const { tools } = await server.listTools(); + +// Clean up +await server.dispose(); +``` + +### CreateConfig Fields + +```typescript +create({ + // Required + info: { name: string; version: string }, + + // App-level (merged into synthetic app) + tools?: ToolType[], + resources?: ResourceType[], + prompts?: PromptType[], + agents?: AgentType[], + skills?: SkillType[], + plugins?: PluginType[], + providers?: ProviderType[], + adapters?: AdapterType[], + auth?: AuthOptionsInput, + + // Server-level + redis?: RedisOptionsInput, + transport?: TransportOptionsInput, + logging?: LoggingOptionsInput, + elicitation?: ElicitationOptionsInput, + + // create()-specific + appName?: string, // defaults to info.name + cacheKey?: string, // same key = reuse server instance + machineId?: string, // stable session ID across restarts +}) +``` + +## Platform-Specific Connections + +Use `connect*()` functions to get tools formatted for a specific LLM platform: + +### OpenAI Function Calling + +```typescript +import { connectOpenAI } from '@frontmcp/sdk'; + +const client = await connectOpenAI(MyServerConfig, { + session: { id: 'user-123', user: { sub: 'user-id' } }, +}); + +const tools = await client.listTools(); +// Returns OpenAI format: [{ type: 'function', function: { name, description, parameters, strict: true } }] + +const result = await client.callTool('my-tool', { arg: 'value' }); +await client.close(); +``` + +### Anthropic Claude + +```typescript +import { connectClaude } from '@frontmcp/sdk'; + +const client = await connectClaude(MyServerConfig); +const tools = await client.listTools(); +// Returns Claude format: [{ name, description, input_schema }] +``` + +### LangChain + +```typescript +import { connectLangChain } from '@frontmcp/sdk'; + +const client = await connectLangChain(MyServerConfig); +const tools = await client.listTools(); +// Returns LangChain tool schema format +``` + +### Vercel AI SDK + +```typescript +import { connectVercelAI } from '@frontmcp/sdk'; + +const client = await connectVercelAI(MyServerConfig); +const tools = await client.listTools(); +// Returns Vercel AI SDK format +``` + +### ConnectOptions + +```typescript +const client = await connectOpenAI(config, { + clientInfo: { name: 'my-app', version: '1.0' }, + session: { id: 'session-123', user: { sub: 'user-id', name: 'Alice' } }, + authToken: 'jwt-token-here', + capabilities: { roots: { listChanged: true } }, +}); +``` + +## DirectClient API + +All `connect*()` functions return a `DirectClient` with these methods: + +| Method | Description | +| ----------------------- | -------------------------------------- | +| `listTools()` | List tools in platform-specific format | +| `callTool(name, args)` | Execute a tool | +| `listResources()` | List available resources | +| `readResource(uri)` | Read a resource | +| `listPrompts()` | List available prompts | +| `getPrompt(name, args)` | Get a prompt | +| `close()` | Clean up connection | + +## SDK vs Node Target + +| Aspect | `--target sdk` | `--target node` | +| ------------ | --------------------------------- | --------------------- | +| Output | CJS + ESM + .d.ts | Single JS executable | +| HTTP server | No (`serve: false`) | Yes (listens on port) | +| Use case | Library/embed in apps | Standalone deployment | +| Distribution | npm package | Docker/binary | +| Tool format | Platform-specific via connect\*() | Raw MCP protocol | + +## Verification + +```bash +# Build +frontmcp build --target sdk + +# Check outputs +ls dist/ +# my-sdk.cjs.js my-sdk.esm.mjs *.d.ts + +# Test programmatically +node -e "const { create } = require('./dist/my-sdk.cjs.js'); ..." +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------- | ------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------- | +| HTTP server | `serve: false` in `@FrontMcp` decorator | Omitting `serve` (defaults to `true`) | SDK mode should not bind a port | +| Dependency bundling | `@frontmcp/*` marked as external | Bundling all `@frontmcp/*` packages | Consumers already have these as peer deps | +| Instance reuse | Pass `cacheKey` to `create()` | Call `create()` on every request | Same key reuses the server instance, avoiding repeated init | +| Cleanup | Call `server.dispose()` or `client.close()` | Letting the process exit without cleanup | Avoids leaked connections and open handles | +| Platform tools | `connectOpenAI()` for OpenAI format | Manually formatting tool schemas | `connect*()` handles schema translation automatically | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target sdk` completes without errors +- [ ] Output contains `.cjs.js`, `.esm.mjs`, and `.d.ts` files +- [ ] `@frontmcp/*` packages are not included in the bundle + +**Programmatic API** + +- [ ] `create()` returns a working server instance +- [ ] `server.callTool()` executes tools and returns results +- [ ] `server.listTools()` returns all registered tools +- [ ] `server.dispose()` cleans up without errors + +**Platform Connections** + +- [ ] `connectOpenAI()` returns tools in OpenAI function-calling format +- [ ] `connectClaude()` returns tools in Anthropic `input_schema` format +- [ ] `client.close()` releases all resources + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------- | +| HTTP server starts unexpectedly | Missing `serve: false` in decorator | Add `serve: false` to the `@FrontMcp` options | +| `create()` returns stale tools | Cached instance from a previous `cacheKey` | Use a unique `cacheKey` or call `dispose()` before re-creating | +| TypeScript types missing | `.d.ts` files not generated | Ensure `tsconfig` has `declaration: true` and build target is `sdk` | +| `connectOpenAI()` format wrong | Using raw `listTools()` instead of platform client | Use `connectOpenAI()` which formats tools for OpenAI automatically | +| Bundle includes `@frontmcp/*` | Build config missing externals | Verify `--target sdk` is set; it marks `@frontmcp/*` as external | + +## Reference + +- **Docs:** +- **Related skills:** `build-for-cli`, `build-for-browser`, `deploy-to-cloudflare` diff --git a/libs/skills/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md new file mode 100644 index 00000000..fbe8a698 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md @@ -0,0 +1,213 @@ +# Deploy a FrontMCP Server to Cloudflare Workers + +This skill guides you through deploying a FrontMCP server to Cloudflare Workers. + + +Cloudflare Workers support is **experimental**. The Express-to-Workers adapter has limitations with streaming, certain middleware, and some response methods. For production Cloudflare deployments, consider using Hono or native Workers APIs. + + +## When to Use This Skill + +### Must Use + +- Deploying a FrontMCP server to Cloudflare Workers +- Configuring `wrangler.toml` for a FrontMCP project targeting Cloudflare +- Setting up Workers KV, D1, or Durable Objects storage for an MCP server on Cloudflare + +### Recommended + +- Evaluating serverless edge deployment options for low-latency MCP endpoints +- Migrating an existing Node.js MCP server to a Cloudflare Workers environment +- Adding a custom domain to a Cloudflare-hosted MCP server + +### Skip When + +- Deploying to a traditional Node.js server or Docker container -- use `build-for-cli` or `--target node` +- Building a browser-based MCP client -- use `build-for-browser` +- Embedding MCP tools in an existing app without HTTP -- use `build-for-sdk` + +> **Decision:** Choose this skill when your deployment target is Cloudflare Workers; otherwise pick the skill that matches your runtime. + +## Prerequisites + +- A Cloudflare account (https://dash.cloudflare.com) +- Wrangler CLI installed: `npm install -g wrangler` +- A built FrontMCP project + +## Step 1: Create a Cloudflare-targeted Project + +```bash +npx frontmcp create my-app --target cloudflare +``` + +This generates the project with a `wrangler.toml` and a deploy script (`npm run deploy` runs `wrangler deploy`). + +## Step 2: Build for Cloudflare + +```bash +frontmcp build --target cloudflare +``` + +This produces: + +```text +dist/ + main.js # Your compiled server (CommonJS) + index.js # Cloudflare handler wrapper +wrangler.toml # Wrangler configuration +``` + +Cloudflare Workers use CommonJS (not ESM). The build command sets `--module commonjs` automatically. + +## Step 3: Configure wrangler.toml + +The generated `wrangler.toml`: + +```toml +name = "frontmcp-worker" +main = "dist/index.js" +compatibility_date = "2024-01-01" + +[vars] +NODE_ENV = "production" +``` + +To add KV storage for sessions and state: + +```toml +name = "frontmcp-worker" +main = "dist/index.js" +compatibility_date = "2024-01-01" + +[[kv_namespaces]] +binding = "FRONTMCP_KV" +id = "your-kv-namespace-id" + +[vars] +NODE_ENV = "production" +``` + +Create the KV namespace via the dashboard or CLI: + +```bash +wrangler kv:namespace create FRONTMCP_KV +``` + +Copy the returned `id` into your `wrangler.toml`. + +## Step 4: Configure the Server + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ name: 'MyApp' }) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-worker', version: '1.0.0' }, + apps: [MyApp], + transport: { + type: 'sse', + }, +}) +class MyServer {} + +export default MyServer; +``` + +For KV-backed session storage, use the Cloudflare KV or Upstash Redis provider. + +## Step 5: Deploy + +```bash +# Preview deployment +wrangler dev + +# Production deployment +wrangler deploy +``` + +### Custom Domain + +Configure a custom domain in the Cloudflare dashboard under **Workers & Pages > your worker > Settings > Domains & Routes**, or via wrangler: + +```bash +wrangler domains add mcp.example.com +``` + +## Step 6: Verify + +```bash +# Health check +curl https://frontmcp-worker.your-subdomain.workers.dev/health + +# Test MCP endpoint +curl -X POST https://frontmcp-worker.your-subdomain.workers.dev/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +## Workers Limitations + +- **Bundle size**: Workers have a 1 MB compressed / 10 MB uncompressed limit (paid plan: 10 MB / 30 MB). Review dependencies and remove unused packages to reduce bundle size. +- **CPU time**: 10 ms CPU time on free plan, 30 seconds on paid. Long-running operations must be optimized or use Durable Objects. +- **No native modules**: `better-sqlite3` and other native Node.js modules are not available. Use KV, D1, or Upstash Redis for storage. +- **Streaming**: SSE streaming may have limitations through the Workers adapter. Test thoroughly. + +## Storage Options + +| Storage | Use Case | Notes | +| ------------- | ----------------------------- | --------------------------------- | +| Cloudflare KV | Simple key-value, low write | Eventually consistent, fast reads | +| Upstash Redis | Sessions, pub/sub, high write | Redis-compatible REST API | +| Cloudflare D1 | Relational data | SQLite-based, serverless | + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------- | +| Worker exceeds size limit | Too many bundled dependencies | Review dependencies and remove unused packages to reduce bundle size | +| Module format errors | `wrangler.toml` sets `type = "module"` | Remove the `type` field; FrontMCP Cloudflare builds use CommonJS | +| KV binding errors | Namespace not created or binding name mismatch | Run `wrangler kv:namespace create` and copy the `id` into `wrangler.toml` | +| Timeout errors | CPU time exceeds plan limit | Upgrade plan or offload heavy computation to Durable Objects | +| CORS failures on MCP endpoint | Missing CORS headers in Worker response | Add CORS middleware or headers in your FrontMCP server configuration | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | ------------------------------------------- | --------------------------------- | -------------------------------------------------- | +| Module format | CommonJS (`main = "dist/index.js"`) | ESM (`type = "module"`) | FrontMCP Cloudflare builds emit CommonJS | +| Storage binding | `[[kv_namespaces]]` with matching `binding` | Hardcoded KV namespace ID in code | Bindings are injected at runtime by Workers | +| Compatibility date | Set to a recent, tested date | Omitting `compatibility_date` | Workers behavior changes across compat dates | +| Build command | `frontmcp build --target cloudflare` | `frontmcp build` (no target) | Default target is Node.js, not Workers | +| Secrets | `wrangler secret put MY_SECRET` | Storing secrets in `[vars]` | `[vars]` are visible in plaintext in the dashboard | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target cloudflare` completes without errors +- [ ] Bundle size is within Cloudflare plan limits (free: 1 MB compressed) + +**Configuration** + +- [ ] `wrangler.toml` has correct `name`, `main`, and `compatibility_date` +- [ ] KV namespace IDs match between dashboard and `wrangler.toml` +- [ ] Secrets are stored via `wrangler secret put`, not in `[vars]` + +**Deployment** + +- [ ] `wrangler dev` serves the MCP endpoint locally +- [ ] `wrangler deploy` succeeds without errors +- [ ] Health endpoint responds with 200 + +**Runtime** + +- [ ] `tools/list` JSON-RPC call returns expected tools +- [ ] SSE streaming works end-to-end (if using SSE transport) +- [ ] Custom domain resolves correctly (if configured) + +## Reference + +- **Docs:** +- **Related skills:** `build-for-cli`, `build-for-browser`, `build-for-sdk` diff --git a/libs/skills/catalog/frontmcp-deployment/references/deploy-to-lambda.md b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-lambda.md new file mode 100644 index 00000000..31cf8cd6 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-lambda.md @@ -0,0 +1,317 @@ +# Deploy a FrontMCP Server to AWS Lambda + +This skill walks you through deploying a FrontMCP server to AWS Lambda with API Gateway using SAM or CDK. + +## When to Use This Skill + +### Must Use + +- Deploying a FrontMCP server to AWS Lambda behind API Gateway +- Setting up a SAM or CDK stack for a serverless MCP endpoint on AWS +- Integrating with AWS-native services like ElastiCache, Secrets Manager, or CloudWatch + +### Recommended + +- Your organization standardizes on AWS and you need IAM-based access control +- You want provisioned concurrency for predictable latency on critical MCP endpoints +- Deploying across multiple AWS regions with infrastructure-as-code (SAM or CDK) + +### Skip When + +- Deploying to Vercel or you prefer a simpler serverless DX -- use `deploy-to-vercel` instead +- You need a long-lived process with WebSockets or persistent connections -- use `deploy-to-node` instead +- You do not use AWS and want to avoid managing IAM roles, VPCs, and CloudFormation stacks + +> **Decision:** Choose this skill when you need serverless deployment within the AWS ecosystem; choose a different target when you want simpler ops or a non-AWS platform. + +## Prerequisites + +- AWS account with appropriate IAM permissions +- AWS CLI configured: `aws configure` +- SAM CLI installed: `brew install aws-sam-cli` (macOS) or see AWS docs +- Node.js 22 or later +- A FrontMCP project ready to build + +## Step 1: Build for Lambda + +```bash +frontmcp build --target lambda +``` + +This produces a Lambda-compatible output with a single handler file optimized for cold-start performance, minimized bundle size with tree-shaking, and a `template.yaml` scaffold for SAM. + +## Step 2: SAM Template + +Create `template.yaml` in your project root: + +```yaml +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: FrontMCP server on AWS Lambda + +Globals: + Function: + Timeout: 30 + Runtime: nodejs22.x + MemorySize: 512 + Environment: + Variables: + NODE_ENV: production + LOG_LEVEL: info + +Resources: + FrontMcpFunction: + Type: AWS::Serverless::Function + Properties: + Handler: handler.handler + CodeUri: . + Description: FrontMCP MCP server + Architectures: + - arm64 + Events: + McpApi: + Type: HttpApi + Properties: + Path: /{proxy+} + Method: ANY + HealthCheck: + Type: HttpApi + Properties: + Path: /health + Method: GET + Environment: + Variables: + REDIS_URL: !If + - HasRedis + - !Ref RedisUrl + - '' + Policies: + - AWSLambdaBasicExecutionRole + + FrontMcpLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda/${FrontMcpFunction} + RetentionInDays: 14 + +Conditions: + HasRedis: !Not [!Equals [!Ref RedisUrl, '']] + +Parameters: + RedisUrl: + Type: String + Default: '' + Description: Redis connection URL for session storage + +Outputs: + ApiEndpoint: + Description: API Gateway endpoint URL + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com' + FunctionArn: + Description: Lambda function ARN + Value: !GetAtt FrontMcpFunction.Arn +``` + +## Step 3: API Gateway + +SAM automatically creates an HTTP API (API Gateway v2) from the `Events` block. The `/{proxy+}` route catches all paths and forwards them to FrontMCP's internal router. + +For more control, define the API explicitly: + +```yaml +Resources: + FrontMcpApi: + Type: AWS::Serverless::HttpApi + Properties: + StageName: prod + CorsConfiguration: + AllowOrigins: + - 'https://your-domain.com' + AllowMethods: + - GET + - POST + - OPTIONS + AllowHeaders: + - Content-Type + - Authorization +``` + +## Step 4: Handler Configuration + +FrontMCP generates a Lambda handler file (`handler.cjs` with a `handler` export) during the build step. In SAM/CDK, reference it as `handler.handler`. To customize the handler, create a `lambda.ts` entry point: + +```typescript +import { createLambdaHandler } from '@frontmcp/adapters/lambda'; +import { AppModule } from './app.module'; + +export const handler = createLambdaHandler(AppModule, { + streaming: false, +}); +``` + +## Step 5: Environment Variables + +Configure environment variables in the SAM template or set them after deployment: + +```bash +aws lambda update-function-configuration \ + --function-name FrontMcpFunction \ + --environment "Variables={NODE_ENV=production,LOG_LEVEL=info,REDIS_URL=redis://your-redis:6379}" +``` + +| Variable | Description | Required | +| ---------------------- | ----------------------------------- | ----------------- | +| `NODE_ENV` | Runtime environment | Yes | +| `REDIS_URL` | Redis/ElastiCache connection string | If using sessions | +| `LOG_LEVEL` | Logging verbosity | No | +| `FRONTMCP_AUTH_SECRET` | Secret for signing auth tokens | If using auth | + +For sensitive values, use AWS Systems Manager Parameter Store or Secrets Manager: + +```yaml +Environment: + Variables: + FRONTMCP_AUTH_SECRET: !Sub '{{resolve:ssm:/frontmcp/auth-secret}}' +``` + +## Step 6: Deploy + +### First Deployment (Guided) + +```bash +sam build +sam deploy --guided +``` + +The guided deployment prompts for stack name, region, and parameter overrides. Answers are saved in `samconfig.toml` for subsequent deploys. + +### Subsequent Deployments + +```bash +sam build && sam deploy +``` + +### CDK Alternative + +If you prefer AWS CDK over SAM: + +```typescript +import * as cdk from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as apigw from 'aws-cdk-lib/aws-apigatewayv2'; +import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; + +const fn = new lambda.Function(this, 'FrontMcpHandler', { + runtime: lambda.Runtime.NODEJS_22_X, + handler: 'handler.handler', + code: lambda.Code.fromAsset('.'), + memorySize: 512, + timeout: cdk.Duration.seconds(30), + architecture: lambda.Architecture.ARM_64, + environment: { + NODE_ENV: 'production', + LOG_LEVEL: 'info', + }, +}); + +const api = new apigw.HttpApi(this, 'FrontMcpApi', { + defaultIntegration: new integrations.HttpLambdaIntegration('LambdaIntegration', fn), +}); +``` + +Deploy with: + +```bash +cdk deploy +``` + +## Step 7: Verify + +```bash +# Get the endpoint from stack outputs +aws cloudformation describe-stacks \ + --stack-name frontmcp-prod \ + --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ + --output text + +# Health check +curl https://abc123.execute-api.us-east-1.amazonaws.com/health +``` + +## Cold Start Mitigation + +Lambda cold starts occur when a new execution environment is initialized. Strategies to reduce their impact: + +1. **Provisioned Concurrency** -- pre-warms execution environments (incurs cost when idle): + + ```yaml + FrontMcpFunction: + Properties: + ProvisionedConcurrencyConfig: + ProvisionedConcurrentExecutions: 5 + ``` + +2. **Small bundles** -- the `frontmcp build --target lambda` output is already optimized, but audit your dependencies. + +3. **ARM64 runtime** -- ARM functions initialize faster than x86. The template uses `arm64` by default. + +4. **Higher memory** -- CPU scales proportionally with memory. 512 MB or 1024 MB is a good starting point. + +### Typical Cold Start Times + +| Memory | Cold Start (ARM64) | Cold Start (x86) | +| ------- | ------------------ | ---------------- | +| 256 MB | ~800ms | ~1000ms | +| 512 MB | ~500ms | ~700ms | +| 1024 MB | ~350ms | ~500ms | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | -------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Build command | `frontmcp build --target lambda` | `tsc` or generic bundler | The Lambda target produces a single optimized handler with tree-shaking for cold-start performance | +| Architecture | `arm64` (Graviton) | `x86_64` | ARM64 functions initialize faster and cost less per ms of compute | +| Handler path | `handler.handler` in SAM template | `index.handler` or `src/lambda.handler` | The FrontMCP build outputs to `dist/`; mismatched paths cause 502 errors | +| Secrets management | SSM Parameter Store or Secrets Manager (`{{resolve:ssm:...}}`) | Plaintext env vars in `template.yaml` | SSM/Secrets Manager encrypts values at rest and supports rotation | +| Redis connectivity | Lambda in same VPC as ElastiCache with security groups | Public Redis endpoint from Lambda | VPC peering ensures low latency and keeps traffic off the public internet | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target lambda` completes without errors +- [ ] `handler.handler` exists and exports a `handler` function + +**SAM / CDK** + +- [ ] `sam build` succeeds without errors +- [ ] `sam deploy --guided` creates the CloudFormation stack +- [ ] Stack outputs include the API Gateway endpoint URL + +**Runtime** + +- [ ] `curl https://.execute-api..amazonaws.com/health` returns `{"status":"ok"}` +- [ ] CloudWatch Logs show successful invocations without errors +- [ ] `NODE_ENV` is set to `production` in the function configuration + +**Production Readiness** + +- [ ] Sensitive values use SSM Parameter Store or Secrets Manager +- [ ] Log retention is configured (e.g., 14 days) +- [ ] If using Redis, Lambda is in the same VPC as ElastiCache with correct security groups +- [ ] Provisioned concurrency is enabled for latency-sensitive endpoints (if applicable) + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Timeout errors | Function timeout too low or waiting on unreachable resource | Increase `Timeout` in the SAM template; verify network connectivity to dependencies | +| 502 Bad Gateway | Handler path mismatch, missing env vars, or unhandled exception | Check CloudWatch Logs; confirm `Handler` matches `handler.handler` | +| Cold starts too slow | Low memory, x86 architecture, or large bundle | Increase memory to 512+ MB, use `arm64`, or enable provisioned concurrency | +| Redis connection refused from Lambda | Lambda not in the same VPC as ElastiCache | Place the Lambda in the ElastiCache VPC with appropriate security group rules | +| `sam deploy` fails with IAM error | Insufficient permissions for CloudFormation stack creation | Ensure the deploying IAM user/role has `cloudformation:*`, `lambda:*`, `apigateway:*`, and `iam:PassRole` | + +## Reference + +- **Docs:** https://docs.agentfront.dev/frontmcp/deployment/serverless +- **Related skills:** `deploy-to-node`, `deploy-to-vercel` diff --git a/libs/skills/catalog/frontmcp-deployment/references/deploy-to-node-dockerfile.md b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-node-dockerfile.md new file mode 100644 index 00000000..8dbbd903 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-node-dockerfile.md @@ -0,0 +1,54 @@ +# ---- Build Stage ---- + +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install dependencies first for better layer caching + +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +# Copy source and build + +COPY . . +RUN yarn frontmcp build --target node + +# ---- Production Stage ---- + +FROM node:22-alpine AS production + +WORKDIR /app + +# Create non-root user for security + +RUN addgroup -S frontmcp && adduser -S frontmcp -G frontmcp + +# Copy only production artifacts + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/yarn.lock ./ + +# Install production dependencies only + +RUN yarn install --frozen-lockfile --production && \ + yarn cache clean + +# Set ownership + +RUN chown -R frontmcp:frontmcp /app + +USER frontmcp + +# Environment defaults + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +CMD ["node", "dist/main.js"] diff --git a/libs/skills/catalog/frontmcp-deployment/references/deploy-to-node.md b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-node.md new file mode 100644 index 00000000..3af54822 --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-node.md @@ -0,0 +1,257 @@ +# Deploy a FrontMCP Server to Node.js + +This skill walks you through deploying a FrontMCP server as a standalone Node.js application, optionally containerized with Docker for production use. + +## When to Use This Skill + +### Must Use + +- Deploying a FrontMCP server to a VPS, dedicated server, or bare-metal infrastructure +- Running a long-lived Node.js process that needs full control over the runtime environment +- Containerizing a FrontMCP server with Docker or Docker Compose for self-hosted production + +### Recommended + +- Using PM2 or systemd to manage a FrontMCP process with automatic restarts +- Deploying behind NGINX or another reverse proxy for TLS termination and load balancing +- Running in environments where serverless cold starts are unacceptable + +### Skip When + +- Deploying to Vercel -- use `deploy-to-vercel` instead +- Deploying to AWS Lambda -- use `deploy-to-lambda` instead +- You need zero-ops serverless scaling and do not require persistent connections or long-running processes + +> **Decision:** Choose this skill when you need a persistent Node.js process with full infrastructure control; choose a serverless skill when you want managed scaling. + +## Prerequisites + +- Node.js 22 or later +- Docker and Docker Compose (recommended for production) +- A FrontMCP project ready to build + +## Step 1: Build the Server + +```bash +frontmcp build --target node +``` + +This compiles your TypeScript source, bundles dependencies, and produces a production-ready output in `dist/`. The build output includes compiled JavaScript optimized for Node.js, a `package.json` with production dependencies only, and any static assets. + +## Step 2: Dockerfile (Multi-Stage) + +Create a multi-stage `Dockerfile` in your project root: + +```dockerfile +# Stage 1: Build +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile +COPY . . +RUN npx frontmcp build --target node + +# Stage 2: Production +FROM node:22-alpine AS production +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package.json ./ +RUN yarn install --frozen-lockfile --production && yarn cache clean +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ + CMD wget -qO- http://localhost:3000/health || exit 1 +CMD ["node", "dist/main.js"] +``` + +The first stage installs all dependencies and builds the project. The second stage copies only the compiled output and production dependencies into a slim image. + +## Step 3: Docker Compose with Redis + +Create a `docker-compose.yml` for a complete deployment with Redis: + +```yaml +version: '3.9' + +services: + frontmcp: + build: + context: . + dockerfile: Dockerfile + ports: + - '${PORT:-3000}:3000' + environment: + - NODE_ENV=production + - PORT=3000 + - REDIS_URL=redis://redis:6379 + - LOG_LEVEL=info + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + redis: + image: redis:7-alpine + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + +volumes: + redis-data: +``` + +Deploy with: + +```bash +docker compose up -d +``` + +## Step 4: Environment Variables + +Create a `.env` file or set variables in your deployment environment: + +```bash +# Server +PORT=3000 +NODE_ENV=production +HOST=0.0.0.0 + +# Redis (required for session storage in production) +REDIS_URL=redis://localhost:6379 + +# Logging +LOG_LEVEL=info +``` + +| Variable | Description | Default | +| ----------- | ----------------------------------- | ------------- | +| `PORT` | HTTP port for the server | `3000` | +| `NODE_ENV` | Runtime environment | `development` | +| `REDIS_URL` | Redis connection string for storage | (none) | +| `HOST` | Network interface to bind | `0.0.0.0` | +| `LOG_LEVEL` | Logging verbosity | `info` | + +## Step 5: Health Checks + +FrontMCP servers expose a `/health` endpoint by default: + +```bash +curl http://localhost:3000/health +# Response: { "status": "ok", "uptime": 12345 } +``` + +For Docker, the `HEALTHCHECK` directive in the Dockerfile and the `healthcheck` block in Compose handle this automatically. Point your load balancer or orchestrator at this endpoint for liveness checks. + +## Step 6: PM2 for Bare Metal + +When running without Docker, use PM2 as a process manager: + +```bash +# Install PM2 globally +npm install -g pm2 + +# Start the server with cluster mode (one instance per CPU core) +pm2 start dist/main.js --name frontmcp-server -i max + +# Save the process list for auto-restart on reboot +pm2 save +pm2 startup +``` + +The `-i max` flag runs one instance per CPU core for optimal throughput. + +## Step 7: NGINX Reverse Proxy + +Place NGINX in front of the server for TLS termination: + +```nginx +server { + listen 443 ssl; + server_name mcp.example.com; + + ssl_certificate /etc/ssl/certs/mcp.example.com.pem; + ssl_certificate_key /etc/ssl/private/mcp.example.com.key; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Resource Limits + +Set appropriate limits in Docker Compose for production: + +```yaml +services: + frontmcp: + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.5' +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------------- | ------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------- | +| Build command | `frontmcp build --target node` | `tsc && node dist/main.js` | The FrontMCP build bundles deps and produces an optimized output | +| Docker base image | `node:22-alpine` (multi-stage) | `node:22` (single stage with dev deps) | Multi-stage keeps the production image small and secure | +| Process manager | PM2 with `-i max` cluster mode | Running `node dist/main.js` directly via `nohup` | PM2 handles restarts, logging, and multi-core clustering | +| Redis hostname in Compose | Service name `redis` | `localhost` or `127.0.0.1` | Containers communicate via Docker's internal DNS, not localhost | +| Environment config | `.env` file or orchestrator env vars | Hardcoded values in source code | Keeps secrets out of the codebase and allows per-environment config | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target node` completes without errors +- [ ] `dist/main.js` exists and is runnable with `node dist/main.js` + +**Docker** + +- [ ] `docker compose up -d` starts all services without errors +- [ ] `docker compose ps` shows all containers as healthy +- [ ] `curl http://localhost:3000/health` returns `{"status":"ok"}` + +**Production Readiness** + +- [ ] `NODE_ENV` is set to `production` +- [ ] Redis is reachable and `REDIS_URL` is configured +- [ ] Resource limits (memory, CPU) are defined in Compose or the orchestrator +- [ ] NGINX or another reverse proxy handles TLS termination +- [ ] Logs are collected and rotated (Docker log driver or PM2 log rotation) + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Port already in use | Another process is bound to the same port | Change the `PORT` environment variable or stop the conflicting process with `lsof -i :3000` | +| Redis connection refused | Redis is not running or `REDIS_URL` is wrong | Verify Redis is running; in Docker Compose use the service name (`redis`) as the hostname | +| Health check failing | Server has not finished starting | Increase `start_period` in the Docker health check to give the server more startup time | +| Out of memory (OOM kill) | Container memory limit is too low | Increase the memory limit in Docker or set `NODE_OPTIONS="--max-old-space-size=1024"` | +| PM2 not restarting on reboot | Startup hook was not saved | Run `pm2 save && pm2 startup` to persist the process list across reboots | + +## Reference + +- **Docs:** https://docs.agentfront.dev/frontmcp/deployment/production-build +- **Related skills:** `deploy-to-vercel`, `deploy-to-lambda` diff --git a/libs/skills/catalog/frontmcp-deployment/references/deploy-to-vercel-config.md b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-vercel-config.md new file mode 100644 index 00000000..b9c41c8a --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-vercel-config.md @@ -0,0 +1,60 @@ +{ +"$schema": "https://openapi.vercel.sh/vercel.json", +"framework": null, +"buildCommand": "frontmcp build --target vercel", +"outputDirectory": "dist", +"rewrites": [ +{ +"source": "/(.*)", +"destination": "/api/frontmcp" +} +], +"functions": { +"api/frontmcp.js": { +"memory": 512, +"maxDuration": 30 +} +}, +"regions": ["iad1"], +"headers": [ +{ +"source": "/health", +"headers": [ +{ +"key": "Cache-Control", +"value": "no-store" +} +] +}, +{ +"source": "/mcp", +"headers": [ +{ +"key": "Cache-Control", +"value": "no-store" +}, +{ +"key": "X-Content-Type-Options", +"value": "nosniff" +} +] +}, +{ +"source": "/(.\*)", +"headers": [ +{ +"key": "X-Frame-Options", +"value": "DENY" +}, +{ +"key": "X-Content-Type-Options", +"value": "nosniff" +}, +{ +"key": "Referrer-Policy", +"value": "strict-origin-when-cross-origin" +} +] +} +] +} diff --git a/libs/skills/catalog/frontmcp-deployment/references/deploy-to-vercel.md b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-vercel.md new file mode 100644 index 00000000..7c5ab19c --- /dev/null +++ b/libs/skills/catalog/frontmcp-deployment/references/deploy-to-vercel.md @@ -0,0 +1,224 @@ +# Deploy a FrontMCP Server to Vercel + +This skill guides you through deploying a FrontMCP server to Vercel serverless functions with Vercel KV for persistent storage. + +## When to Use This Skill + +### Must Use + +- Deploying a FrontMCP server to Vercel serverless functions +- Configuring Vercel KV as the persistence layer for sessions and skill cache +- Setting up a serverless MCP endpoint with automatic TLS and global CDN + +### Recommended + +- You already use Vercel for your frontend and want a unified deployment pipeline +- You need zero-ops scaling without managing Docker containers or servers +- Deploying preview environments per pull request for MCP server testing + +### Skip When + +- You need persistent connections, WebSockets, or long-running processes -- use `deploy-to-node` instead +- Deploying to AWS infrastructure or need AWS-specific services -- use `deploy-to-lambda` instead +- Your MCP operations routinely exceed the Vercel function timeout for your plan + +> **Decision:** Choose this skill when you want serverless deployment on Vercel with minimal infrastructure management; choose a different target when you need persistent processes or AWS-native services. + +## Prerequisites + +- A Vercel account (https://vercel.com) +- Vercel CLI installed: `npm install -g vercel` +- A built FrontMCP project + +## Step 1: Build for Vercel + +```bash +frontmcp build --target vercel +``` + +This produces a Vercel-compatible output structure with an `api/` directory containing the serverless function entry points, optimized bundles for cold-start performance, and a `vercel.json` configuration file. + +## Step 2: Configure vercel.json + +Create or update `vercel.json` in your project root: + +```json +{ + "rewrites": [{ "source": "/(.*)", "destination": "/api/frontmcp" }], + "functions": { + "api/frontmcp.ts": { + "memory": 1024, + "maxDuration": 60 + } + }, + "regions": ["iad1"], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" } + ] + } + ] +} +``` + +The rewrite rule sends all requests to the single FrontMCP API handler, which internally routes MCP and HTTP requests. + +## Step 3: Configure Vercel KV Storage + +Use the `vercel-kv` provider so FrontMCP stores sessions, skill cache, and plugin state in Vercel KV (powered by Upstash Redis): + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ name: 'MyApp' }) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + redis: { provider: 'vercel-kv' }, + skillsConfig: { + enabled: true, + cache: { + enabled: true, + redis: { provider: 'vercel-kv' }, + ttlMs: 60000, + }, + }, +}) +class MyServer {} +``` + +Provision the KV store in the Vercel dashboard under **Storage > Create Database > KV (Redis)**, then link it to your project. Vercel automatically injects the required environment variables. + +## Step 4: Environment Variables + +Vercel KV variables are injected automatically when the store is linked. For manual setup or additional configuration, set them in the Vercel dashboard (**Settings > Environment Variables**) or via the CLI: + +```bash +vercel env add KV_REST_API_URL "https://your-kv-store.kv.vercel-storage.com" +vercel env add KV_REST_API_TOKEN "your-token" +vercel env add NODE_ENV production +vercel env add LOG_LEVEL info +``` + +| Variable | Description | Required | +| ------------------- | ------------------------------ | ----------- | +| `KV_REST_API_URL` | Vercel KV REST endpoint | If using KV | +| `KV_REST_API_TOKEN` | Vercel KV authentication token | If using KV | +| `NODE_ENV` | Runtime environment | Yes | +| `LOG_LEVEL` | Logging verbosity | No | + +## Step 5: Deploy + +### Preview Deployment + +```bash +vercel +``` + +Creates a preview deployment with a unique URL for validation. + +### Production Deployment + +```bash +vercel --prod +``` + +Deploys to your production domain. + +### Custom Domain + +```bash +vercel domains add mcp.example.com +``` + +Configure your DNS provider to point the domain to Vercel. TLS certificates are provisioned automatically. + +## Step 6: Verify + +```bash +# Health check +curl https://your-project.vercel.app/health + +# Test MCP endpoint +curl -X POST https://your-project.vercel.app/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +## Cold Start Notes + +Vercel serverless functions experience cold starts after periods of inactivity. To minimize impact: + +- The `frontmcp build --target vercel` output is optimized for bundle size. Avoid adding unnecessary dependencies. +- Consider Vercel's **Fluid Compute** for latency-sensitive workloads. +- Keep function memory at 1024 MB for faster initialization. + +### Execution Limits + +| Plan | Max Duration | +| ---------- | ------------ | +| Hobby | 10 seconds | +| Pro | 60 seconds | +| Enterprise | 900 seconds | + +Long-running MCP operations should complete within these limits or use streaming responses. + +### Statelessness + +Serverless functions are stateless between invocations. All persistent state must go through Vercel KV. FrontMCP handles this automatically when `{ provider: 'vercel-kv' }` is configured. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------- | ------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------- | +| Build command | `frontmcp build --target vercel` | `tsc` or generic `npm run build` | The Vercel target produces optimized bundles and the `api/` directory structure | +| KV provider config | `{ provider: 'vercel-kv' }` | `{ provider: 'redis', host: '...' }` | Vercel KV uses its own REST API; a raw Redis provider will not connect | +| Rewrite rule | `"source": "/(.*)"` to `/api/frontmcp` | No rewrite or per-route entries | A single catch-all rewrite lets FrontMCP's internal router handle all paths | +| Environment variables | Link KV store in dashboard (auto-injected) | Hardcode `KV_REST_API_URL` in source | Linked stores inject vars automatically and rotate tokens safely | +| Function memory | 1024 MB for faster cold starts | 128 MB default | CPU scales with memory on Vercel; higher memory reduces initialization time | + +## Verification Checklist + +**Build** + +- [ ] `frontmcp build --target vercel` completes without errors +- [ ] `api/frontmcp.ts` (or `.js`) exists in the build output + +**Deployment** + +- [ ] `vercel` creates a preview deployment without errors +- [ ] `vercel --prod` deploys to the production domain +- [ ] `curl https://your-project.vercel.app/health` returns `{"status":"ok"}` + +**Storage and Configuration** + +- [ ] Vercel KV store is created and linked to the project +- [ ] `KV_REST_API_URL` and `KV_REST_API_TOKEN` are present in environment variables +- [ ] `NODE_ENV` is set to `production` +- [ ] `vercel.json` has correct rewrite, function config, and region settings + +**Production Readiness** + +- [ ] Custom domain is configured with DNS pointing to Vercel +- [ ] TLS certificate is provisioned (automatic on Vercel) +- [ ] `maxDuration` in `vercel.json` matches your Vercel plan limits + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Function timeout | Operation exceeds `maxDuration` or plan limit | Increase `maxDuration` in `vercel.json`; check plan limits (Hobby: 10s, Pro: 60s) | +| KV connection errors | KV store not linked or env vars missing | Re-link the KV store in the Vercel dashboard; verify `KV_REST_API_URL` and `KV_REST_API_TOKEN` | +| 404 on API routes | Rewrite rule missing or misconfigured | Confirm `vercel.json` has `"source": "/(.*)"` rewriting to `/api/frontmcp` | +| Bundle too large | Unnecessary dependencies included | Review dependencies and remove unused packages to reduce bundle size | +| Cold starts too slow | Low function memory or large bundle | Increase memory to 1024 MB; audit dependencies; consider Vercel Fluid Compute | + +## Reference + +- **Docs:** https://docs.agentfront.dev/frontmcp/deployment/serverless +- **Related skills:** `deploy-to-node`, `deploy-to-lambda` diff --git a/libs/skills/catalog/frontmcp-development/SKILL.md b/libs/skills/catalog/frontmcp-development/SKILL.md new file mode 100644 index 00000000..f02cab51 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/SKILL.md @@ -0,0 +1,118 @@ +--- +name: frontmcp-development +description: "Domain router for building MCP components \u2014 tools, resources, prompts, agents, providers, jobs, workflows, and skills. Use when starting any FrontMCP development task and need to find the right skill." +tags: [router, development, tools, resources, prompts, agents, skills, guide] +priority: 10 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/servers/overview +--- + +# FrontMCP Development Router + +Entry point for building MCP server components. This skill helps you find the right development skill based on what you want to build. It does not teach implementation details itself — it routes you to the specific skill that does. + +## When to Use This Skill + +### Must Use + +- Starting a FrontMCP development task and unsure which component type to build (tool vs resource vs prompt vs agent) +- Onboarding to the FrontMCP development model and need an overview of all building blocks +- Planning a feature that may require multiple component types working together + +### Recommended + +- Looking up the canonical name of a development skill to install or search +- Comparing component types to decide which fits your use case +- Understanding how tools, resources, prompts, agents, and skills relate to each other + +### Skip When + +- You already know which component to build (go directly to `create-tool`, `create-resource`, etc.) +- You need to configure server settings, not build components (see `frontmcp-config`) +- You need to deploy or build, not develop (see `frontmcp-deployment`) + +> **Decision:** Use this skill when you need to figure out WHAT to build. Use the specific skill when you already know. + +## Scenario Routing Table + +| Scenario | Skill | Description | +| -------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------ | +| Expose an executable action that AI clients can call | `create-tool` | Class-based or function-style tools with Zod input/output validation | +| Expose read-only data via a URI | `create-resource` | Static resources or URI template resources for dynamic data | +| Create a reusable conversation template or system prompt | `create-prompt` | Prompt entries with arguments and multi-turn message sequences | +| Build an autonomous AI loop that orchestrates tools | `create-agent` | Agent entries with LLM config, inner tools, and swarm handoff | +| Register shared services or configuration via DI | `create-provider` | Dependency injection tokens, lifecycle hooks, factory providers | +| Run a background task with progress and retries | `create-job` | Job entries with attempt tracking, retry config, and progress | +| Chain multiple jobs into a sequential pipeline | `create-workflow` | Workflow entries that compose jobs with data passing | +| Write instruction-only AI guidance (no code execution) | `create-skill` | Skill entries with markdown instructions from files, strings, or URLs | +| Write AI guidance that also orchestrates tools | `create-skill-with-tools` | Skill entries that combine instructions with registered tools | +| Look up any decorator signature or option | `decorators-guide` | Complete reference for @Tool, @Resource, @Prompt, @Agent, @App, @FrontMcp, and more | +| Integrate an external API via OpenAPI spec | `official-adapters` | OpenApiAdapter with auth, polling, inline specs, and multiple API composition | +| Use official plugins (caching, remember, feature flags) | `official-plugins` | Built-in plugins for caching, session memory, approval, feature flags, and dashboard | + +## Recommended Reading Order + +1. **`decorators-guide`** — Start here to understand the full decorator landscape +2. **`create-tool`** — The most common building block; learn tools first +3. **`create-resource`** — Expose data alongside tools +4. **`create-prompt`** — Add reusable conversation templates +5. **`create-provider`** — Share services across tools and resources via DI +6. **`create-agent`** — Build autonomous AI loops (advanced) +7. **`create-job`** / **`create-workflow`** — Background processing (advanced) +8. **`create-skill`** / **`create-skill-with-tools`** — Author your own skills (meta) +9. **`official-adapters`** — Integrate external APIs via OpenAPI specs +10. **`official-plugins`** — Add caching, session memory, feature flags, and more + +## Cross-Cutting Patterns + +| Pattern | Applies To | Rule | +| ----------------- | --------------------------------- | -------------------------------------------------------------------------------------- | +| Naming convention | Tools | Use `snake_case` for tool names (`get_weather`, not `getWeather`) | +| Naming convention | Skills, resources | Use `kebab-case` for skill and resource names | +| File naming | All components | Use `..ts` pattern (e.g., `fetch-weather.tool.ts`) | +| DI access | Tools, resources, prompts, agents | Use `this.get(TOKEN)` (throws) or `this.tryGet(TOKEN)` (returns undefined) | +| Error handling | All components | Use `this.fail(err)` with MCP error classes, not raw `throw` | +| Input validation | Tools | Always use Zod raw shapes (not `z.object()`) for `inputSchema` | +| Output validation | Tools | Always define `outputSchema` to prevent data leaks | +| Registration | All components | Add to `tools`, `resources`, `prompts`, `agents`, etc. arrays in `@App` or `@FrontMcp` | +| Test files | All components | Use `.spec.ts` extension, never `.test.ts` | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------------- | --------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------- | +| Choosing component type | Tool for actions, Resource for data, Prompt for templates | Using a tool to return static data | Each type has protocol-level semantics; misuse confuses AI clients | +| Component registration | Register in `@App` arrays, compose apps in `@FrontMcp` | Register tools directly in `@FrontMcp` without an `@App` | Apps provide modularity; direct registration bypasses app-level hooks | +| Shared logic | Extract to a `@Provider` and inject via DI | Duplicate code across multiple tools | Providers are testable, lifecycle-managed, and scoped | +| Complex orchestration | Use `@Agent` with inner tools | Chain tool calls manually in a single tool | Agents handle LLM loops, retries, and tool selection automatically | +| Background work | Use `@Job` with retry config | Run long tasks inside a tool's `execute()` | Jobs have progress tracking, attempt awareness, and timeout handling | + +## Verification Checklist + +### Architecture + +- [ ] Each component type matches its semantic purpose (action=tool, data=resource, template=prompt) +- [ ] Shared services use `@Provider` with DI tokens, not module-level singletons +- [ ] Components are registered in `@App` arrays, apps composed in `@FrontMcp` + +### Development Workflow + +- [ ] Files follow `..ts` naming convention +- [ ] Each component has a corresponding `.spec.ts` test file +- [ ] `decorators-guide` consulted for unfamiliar decorator options + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Unsure which component type to use | Requirements are ambiguous | Check the Scenario Routing Table above; if the action modifies state, use a tool; if it returns data by URI, use a resource | +| Component not discovered at runtime | Not registered in `@App` or `@FrontMcp` arrays | Add to the appropriate array (`tools`, `resources`, `prompts`, etc.) | +| DI token not resolving | Provider not registered in scope | Register the provider in the `providers` array of the same `@App` | +| Need both AI guidance and tool execution | Used `create-skill` but need tools too | Switch to `create-skill-with-tools` which combines instructions with registered tools | + +## Reference + +- [Server Overview](https://docs.agentfront.dev/frontmcp/servers/overview) +- Related skills: `create-tool`, `create-resource`, `create-prompt`, `create-agent`, `create-provider`, `create-job`, `create-workflow`, `create-skill`, `create-skill-with-tools`, `decorators-guide`, `official-adapters`, `official-plugins` diff --git a/libs/skills/catalog/frontmcp-development/references/create-adapter.md b/libs/skills/catalog/frontmcp-development/references/create-adapter.md new file mode 100644 index 00000000..5ed180f0 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-adapter.md @@ -0,0 +1,165 @@ +# Creating Custom Adapters + +Build adapters that automatically generate MCP tools, resources, and prompts from external sources — databases, GraphQL schemas, proprietary APIs, or any definition format. + +## When to Use This Skill + +### Must Use + +- Integrating a non-OpenAPI source (GraphQL, gRPC, database schema) that should generate MCP tools automatically +- Building a reusable adapter that converts external definitions into tools, resources, or prompts at startup +- Creating tools dynamically at runtime based on external state or configuration + +### Recommended + +- Wrapping a proprietary internal API that has its own schema format +- Auto-generating tools from a database schema or config file on server start +- Building an adapter that polls an external source and refreshes tool definitions periodically + +### Skip When + +- The external API has an OpenAPI/Swagger spec (see `official-adapters`) +- You need cross-cutting middleware behavior like logging or caching (see `create-plugin`) +- You are building a single static tool manually (see `create-tool`) + +> **Decision:** Use this skill when you need to auto-generate MCP tools, resources, or prompts from a non-OpenAPI external source by extending `DynamicAdapter`. + +## Step 1: Extend DynamicAdapter + +```typescript +import { DynamicAdapter, type FrontMcpAdapterResponse } from '@frontmcp/sdk'; + +interface MyAdapterOptions { + endpoint: string; + apiKey: string; +} + +class MyApiAdapter extends DynamicAdapter { + declare __options_brand: MyAdapterOptions; + + async fetch(): Promise { + // Fetch definitions from external source + const res = await globalThis.fetch(this.options.endpoint, { + headers: { Authorization: `Bearer ${this.options.apiKey}` }, + }); + const schema = await res.json(); + + // Convert to MCP tool definitions + return { + tools: schema.operations.map((op: { name: string; description: string; params: Record }) => ({ + name: op.name, + description: op.description, + inputSchema: this.convertParams(op.params), + execute: async (input: Record) => { + return this.callApi(op.name, input); + }, + })), + resources: [], + prompts: [], + }; + } + + private convertParams(params: Record) { + // Convert external param definitions to Zod schemas + // ... + } + + private async callApi(operation: string, input: Record) { + // Call the external API + // ... + } +} +``` + +## Step 2: Register + +```typescript +@App({ + name: 'MyApp', + adapters: [ + MyApiAdapter.init({ + name: 'my-api', + endpoint: 'https://api.example.com/schema', + apiKey: process.env.API_KEY!, + }), + ], +}) +class MyApp {} +``` + +## FrontMcpAdapterResponse + +The `fetch()` method returns tools, resources, and prompts to register: + +```typescript +interface FrontMcpAdapterResponse { + tools?: AdapterToolDefinition[]; + resources?: AdapterResourceDefinition[]; + prompts?: AdapterPromptDefinition[]; +} +``` + +## Static init() + +`DynamicAdapter` provides a static `init()` method inherited by all subclasses: + +```typescript +// Usage — no manual instantiation needed +const adapter = MyApiAdapter.init({ + name: 'my-api', // Required: adapter name (used for tool namespacing) + endpoint: '...', + apiKey: '...', +}); + +// Register in @App +@App({ adapters: [adapter] }) +``` + +## Nx Generator + +```bash +nx generate @frontmcp/nx:adapter my-adapter --project=my-app +``` + +Creates a `DynamicAdapter` subclass in `src/adapters/my-adapter.adapter.ts`. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------------- | ------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------- | +| Adapter registration | `MyAdapter.init({ name: 'my-api', ... })` in `adapters` array | `new MyAdapter({ ... })` directly | `init()` returns the proper provider entry for DI wiring | +| Options branding | `declare __options_brand: MyAdapterOptions;` in adapter class | Omitting the brand declaration | Brand ensures TypeScript infers the correct options type for `init()` | +| Fetch return type | Return `{ tools: [...], resources: [...], prompts: [...] }` | Returning raw API response without conversion | `fetch()` must return `FrontMcpAdapterResponse` with MCP-compatible definitions | +| Tool naming | Namespace tools: `name: 'my-api:operation-name'` | Flat names without namespace: `name: 'operation-name'` | Namespacing prevents collisions when multiple adapters are registered | +| Error handling in fetch | Throw descriptive errors with endpoint info | Silently returning empty arrays on failure | Adapter errors should surface at startup so misconfigurations are caught early | + +## Verification Checklist + +### Configuration + +- [ ] Adapter class extends `DynamicAdapter` +- [ ] `__options_brand` is declared with the correct options type +- [ ] `fetch()` method is implemented and returns `FrontMcpAdapterResponse` +- [ ] Adapter is registered via `.init()` in the `adapters` array of `@App` + +### Runtime + +- [ ] Generated tools appear in `tools/list` MCP response +- [ ] Tool names are namespaced with the adapter name (e.g., `my-api:operationId`) +- [ ] Generated tools accept valid input and return expected output +- [ ] Adapter fetch errors produce clear startup error messages + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------- | +| No tools appear after adapter registration | `fetch()` returns empty `tools` array | Verify external source is reachable and response is parsed correctly | +| TypeScript error on `.init()` options | Missing `__options_brand` declaration | Add `declare __options_brand: MyAdapterOptions;` to the adapter class | +| Tool input validation fails | `inputSchema` conversion does not produce valid Zod schemas | Verify `convertParams` produces `z.object()` shapes matching the external schema | +| Duplicate tool name error | Multiple adapters produce tools with the same name | Use unique `name` parameter in `init()` to namespace tools | +| Adapter not found at runtime | Registered in wrong `@App` or not in `adapters` array | Ensure `.init()` result is in the `adapters` array of the correct `@App` | + +## Reference + +- [Adapter Documentation](https://docs.agentfront.dev/frontmcp/adapters/overview) +- Related skills: `official-adapters`, `create-plugin`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-development/references/create-agent-llm-config.md b/libs/skills/catalog/frontmcp-development/references/create-agent-llm-config.md new file mode 100644 index 00000000..0c777c42 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-agent-llm-config.md @@ -0,0 +1,46 @@ +# Agent LLM Configuration Reference + +## Supported Providers + +### Anthropic + +```typescript +llm: { + provider: 'anthropic', // Any supported provider — 'anthropic', 'openai', etc. + model: 'claude-sonnet-4-20250514', // Any supported model for the chosen provider + apiKey: { env: 'ANTHROPIC_API_KEY' }, + maxTokens: 4096, +} +``` + +### OpenAI + +```typescript +llm: { + provider: 'openai', + model: 'gpt-4o', // Any supported model for the chosen provider + apiKey: { env: 'OPENAI_API_KEY' }, + maxTokens: 4096, +} +``` + +## API Key Sources + +```typescript +// From environment variable (recommended) +apiKey: { + env: 'ANTHROPIC_API_KEY'; +} + +// Direct string (not recommended for production) +apiKey: 'sk-...'; +``` + +## Common Models + +| Provider | Model | Use Case | +| --------- | -------------------------- | -------------------- | +| Anthropic | `claude-sonnet-4-20250514` | Fast, capable | +| Anthropic | `claude-opus-4-20250514` | Most capable | +| OpenAI | `gpt-4o` | General purpose | +| OpenAI | `gpt-4o-mini` | Fast, cost-effective | diff --git a/libs/skills/catalog/frontmcp-development/references/create-agent.md b/libs/skills/catalog/frontmcp-development/references/create-agent.md new file mode 100644 index 00000000..fe54e3c3 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-agent.md @@ -0,0 +1,601 @@ +# Creating an Autonomous Agent + +Agents are autonomous AI entities that use an LLM to reason, plan, and invoke inner tools to accomplish goals. In FrontMCP, agents are TypeScript classes that extend `AgentContext`, decorated with `@Agent`, and registered on a `@FrontMcp` server or inside an `@App`. + +## When to Use This Skill + +### Must Use + +- Building an autonomous AI entity that uses LLM reasoning to decide which tools to call +- Orchestrating multi-step workflows where the agent plans, acts, and iterates toward a goal +- Creating multi-agent swarms with handoff between specialized agents + +### Recommended + +- Performing complex tasks that require chaining multiple inner tools with LLM-driven decisions +- Implementing structured multi-pass review (security pass, quality pass, synthesis) +- Composing nested sub-agents with different LLM configs for specialized subtasks + +### Skip When + +- You need a direct, deterministic function that executes a single action (see `create-tool`) +- You are building a reusable conversation template without autonomous execution (see `create-prompt`) +- You only need to expose readable data at a URI (see `create-resource`) + +> **Decision:** Use this skill when the task requires autonomous LLM-driven reasoning, tool invocation, and iterative planning -- not a single deterministic action. + +### @Agent vs @Tool Quick Comparison + +| Aspect | @Agent | @Tool | +| --------------- | ------------------------------- | ---------------------------- | +| Execution | Autonomous LLM loop | Direct function call | +| Decision making | LLM chooses what to do | Caller decides | +| Inner tools | Has its own tools it can invoke | No inner tools | +| Use case | Complex, multi-step workflows | Single, well-defined actions | + +## Class-Based Pattern + +Create a class extending `AgentContext` and optionally override the `execute(input: In): Promise` method. The `@Agent` decorator requires `name`, `description`, and `llm` configuration. + +```typescript +import { Agent, AgentContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Agent({ + name: 'code_reviewer', + description: 'Reviews code changes and provides feedback', + llm: { + provider: 'anthropic', // Any supported provider — 'anthropic', 'openai', etc. + model: 'claude-sonnet-4-20250514', // Any supported model for the chosen provider + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + diff: z.string().describe('The code diff to review'), + language: z.string().optional().describe('Programming language'), + }, + systemInstructions: 'You are an expert code reviewer. Focus on correctness, performance, and maintainability.', +}) +class CodeReviewerAgent extends AgentContext { + async execute(input: { diff: string; language?: string }) { + // Default behavior: runs the agent loop automatically + // The agent will use its LLM to analyze the diff and produce a review + return super.execute(input); + } +} +``` + +### Available Context Methods and Properties + +`AgentContext` extends `ExecutionContextBase`, which provides: + +**Agent-Specific Methods:** + +- `execute(input: In): Promise` -- the main method; default runs the agent loop +- `completion(prompt: AgentPrompt, options?): Promise` -- make a single LLM call +- `streamCompletion(prompt: AgentPrompt, options?): AsyncIterable` -- stream an LLM response +- `executeTool(toolDef, input): Promise` -- (protected) invoke one of the agent's inner tools programmatically + +**Inherited Methods:** + +- `this.get(token)` -- resolve a dependency from DI (throws if not found) +- `this.tryGet(token)` -- resolve a dependency from DI (returns `undefined` if not found) +- `this.fail(err)` -- abort execution, triggers error flow (never returns) +- `this.mark(stage)` -- set the active execution stage for debugging/tracking +- `this.fetch(input, init?)` -- HTTP fetch with context propagation +- `this.notify(message, level?)` -- send a log-level notification to the client +- `this.respondProgress(value, total?)` -- send a progress notification to the client + +**Properties:** + +- `this.input` -- the validated input object +- `this.output` -- the output (available after execute) +- `this.llmAdapter` -- the configured LLM adapter instance +- `this.toolDefinitions` -- definitions of inner tools available to the agent +- `this.toolExecutor` -- executor for invoking inner tools +- `this.metadata` -- agent metadata from the decorator +- `this.scope` -- the current scope instance +- `this.context` -- the execution context + +## LLM Configuration + +The `llm` field is required and configures which LLM provider and model the agent uses. + +```typescript +@Agent({ + name: 'my_agent', + description: 'An agent with LLM config', + llm: { + provider: 'anthropic', // 'anthropic' or 'openai' + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, // read from env var + }, +}) +``` + +The `apiKey` field accepts either an object `{ env: 'ENV_VAR_NAME' }` to read from environment variables, or a string value directly (not recommended for production). + +```typescript +// OpenAI example +llm: { + provider: 'openai', + model: 'gpt-4o', + apiKey: { env: 'OPENAI_API_KEY' }, +}, +``` + +## Custom execute() vs Default Agent Loop + +By default, calling `execute()` runs the full agent loop: the LLM receives the input plus system instructions, decides which inner tools to call, processes results, and iterates until it produces a final answer. + +Override `execute()` when you need custom orchestration logic: + +```typescript +@Agent({ + name: 'structured_reviewer', + description: 'Reviews code with a structured multi-pass approach', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + code: z.string().describe('Source code to review'), + }, + outputSchema: { + issues: z.array( + z.object({ + severity: z.enum(['error', 'warning', 'info']), + line: z.number(), + message: z.string(), + }), + ), + summary: z.string(), + }, +}) +class StructuredReviewerAgent extends AgentContext { + async execute(input: { code: string }) { + this.mark('security-pass'); + const securityReview = await this.completion({ + messages: [{ role: 'user', content: `Review this code for security issues:\n${input.code}` }], + }); + + this.mark('quality-pass'); + const qualityReview = await this.completion({ + messages: [{ role: 'user', content: `Review this code for quality issues:\n${input.code}` }], + }); + + this.mark('synthesis'); + const finalReview = await this.completion({ + messages: [ + { + role: 'user', + content: `Combine these reviews into a structured report:\nSecurity: ${securityReview.content}\nQuality: ${qualityReview.content}`, + }, + ], + }); + + return JSON.parse(finalReview.content); + } +} +``` + +## completion() and streamCompletion() + +Use `completion()` for a single LLM call that returns the full response, and `streamCompletion()` for streaming responses token by token. + +```typescript +@Agent({ + name: 'summarizer', + description: 'Summarizes text using LLM', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + text: z.string().describe('Text to summarize'), + }, +}) +class SummarizerAgent extends AgentContext { + async execute(input: { text: string }) { + // Single completion call + const result = await this.completion( + { + messages: [{ role: 'user', content: `Summarize this text:\n${input.text}` }], + }, + { + maxTokens: 500, + temperature: 0.3, + }, + ); + + return result.content; + } +} +``` + +Streaming example: + +```typescript +async execute(input: { text: string }) { + const stream = this.streamCompletion({ + messages: [{ role: 'user', content: `Analyze this text:\n${input.text}` }], + }); + + let fullResponse = ''; + for await (const chunk of stream) { + fullResponse += chunk.delta; + await this.notify(`Processing: ${fullResponse.length} chars`, 'debug'); + } + + return fullResponse; +} +``` + +## Inner Tools + +The `tools` array in `@Agent` metadata defines tools that the agent itself can invoke during its reasoning loop. These are NOT exposed to external callers -- they are private to the agent. + +```typescript +import { Tool, ToolContext, Agent, AgentContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'fetch_pr', + description: 'Fetch pull request details from GitHub', + inputSchema: { + owner: z.string(), + repo: z.string(), + number: z.number(), + }, +}) +class FetchPRTool extends ToolContext { + async execute(input: { owner: string; repo: string; number: number }) { + const response = await this.fetch( + `https://api.github.com/repos/${input.owner}/${input.repo}/pulls/${input.number}`, + ); + return response.json(); + } +} + +@Tool({ + name: 'post_review_comment', + description: 'Post a review comment on a PR', + inputSchema: { + owner: z.string(), + repo: z.string(), + number: z.number(), + body: z.string(), + }, +}) +class PostReviewCommentTool extends ToolContext { + async execute(input: { owner: string; repo: string; number: number; body: string }) { + await this.fetch(`https://api.github.com/repos/${input.owner}/${input.repo}/pulls/${input.number}/reviews`, { + method: 'POST', + body: JSON.stringify({ body: input.body, event: 'COMMENT' }), + }); + return 'Comment posted'; + } +} + +@Agent({ + name: 'pr_reviewer', + description: 'Autonomously reviews GitHub pull requests', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + owner: z.string().describe('Repository owner'), + repo: z.string().describe('Repository name'), + prNumber: z.number().describe('PR number to review'), + }, + systemInstructions: 'You are a senior code reviewer. Fetch the PR, analyze changes, and post a thorough review.', + tools: [FetchPRTool, PostReviewCommentTool], // Inner tools the agent can use +}) +class PRReviewerAgent extends AgentContext { + // Default execute() runs the agent loop. + // The agent will autonomously call FetchPRTool, analyze the diff, + // and call PostReviewCommentTool to leave a review. +} +``` + +## Exported Tools + +Use `exports: { tools: [] }` to expose specific tools that the agent makes available to external callers. Unlike inner tools (which the agent uses privately), exported tools appear in the MCP tool listing for clients to invoke directly. + +```typescript +@Agent({ + name: 'data_pipeline', + description: 'Data processing pipeline agent', + llm: { + provider: 'openai', + model: 'gpt-4o', + apiKey: { env: 'OPENAI_API_KEY' }, + }, + tools: [ExtractTool, TransformTool, LoadTool], // Agent uses these internally + exports: { tools: [ValidateDataTool, StatusTool] }, // These are exposed to MCP clients +}) +class DataPipelineAgent extends AgentContext {} +``` + +## Nested Agents (Sub-Agents) + +Use the `agents` array to compose agents from smaller, specialized sub-agents. Each sub-agent has its own LLM config, inner tools, and system instructions. + +```typescript +@Agent({ + name: 'security_auditor', + description: 'Audits code for security vulnerabilities', + llm: { provider: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: { env: 'ANTHROPIC_API_KEY' } }, + systemInstructions: 'Focus on OWASP Top 10 vulnerabilities.', + tools: [StaticAnalysisTool], +}) +class SecurityAuditorAgent extends AgentContext {} + +@Agent({ + name: 'performance_auditor', + description: 'Audits code for performance issues', + llm: { provider: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: { env: 'ANTHROPIC_API_KEY' } }, + systemInstructions: 'Focus on time complexity, memory leaks, and N+1 queries.', + tools: [ProfilerTool], +}) +class PerformanceAuditorAgent extends AgentContext {} + +@Agent({ + name: 'code_auditor', + description: 'Comprehensive code auditor that delegates to specialized sub-agents', + llm: { provider: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: { env: 'ANTHROPIC_API_KEY' } }, + inputSchema: { + repository: z.string().describe('Repository URL'), + branch: z.string().default('main').describe('Branch to audit'), + }, + agents: [SecurityAuditorAgent, PerformanceAuditorAgent], // Sub-agents + tools: [CloneRepoTool, GenerateReportTool], + systemInstructions: + 'Clone the repo, delegate security and performance audits to sub-agents, then compile a final report.', +}) +class CodeAuditorAgent extends AgentContext {} +``` + +## Swarm Configuration + +Swarm mode enables multi-agent handoff, where agents can transfer control to each other during execution. Configure swarms using the `swarm` field. + +```typescript +@Agent({ + name: 'triage_agent', + description: 'Triages incoming requests and hands off to specialists', + llm: { provider: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: { env: 'ANTHROPIC_API_KEY' } }, + inputSchema: { + request: z.string().describe('The incoming user request'), + }, + swarm: { + role: 'coordinator', + handoff: [ + { agent: 'billing_agent', condition: 'Request is about billing or payments' }, + { agent: 'technical_agent', condition: 'Request is about technical issues' }, + { agent: 'general_agent', condition: 'Request does not match other categories' }, + ], + }, + systemInstructions: 'Analyze the request and hand off to the appropriate specialist agent.', +}) +class TriageAgent extends AgentContext {} + +@Agent({ + name: 'billing_agent', + description: 'Handles billing and payment inquiries', + llm: { provider: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: { env: 'ANTHROPIC_API_KEY' } }, + tools: [LookupInvoiceTool, ProcessRefundTool], + swarm: { + role: 'specialist', + handoff: [{ agent: 'triage_agent', condition: 'Request is outside billing scope' }], + }, +}) +class BillingAgent extends AgentContext {} +``` + +## Function-Style Builder + +For agents that do not need a class, use the `agent()` function builder. + +```typescript +import { agent } from '@frontmcp/sdk'; +import { z } from 'zod'; + +const QuickSummarizer = agent({ + name: 'quick_summarizer', + description: 'Summarizes text quickly', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + text: z.string().describe('Text to summarize'), + maxLength: z.number().default(100).describe('Max summary length'), + }, +})((input, ctx) => { + // Custom logic using ctx for completion calls + return ctx.completion({ + messages: [{ role: 'user', content: `Summarize in ${input.maxLength} chars:\n${input.text}` }], + }); +}); +``` + +Register it the same way as a class agent: `agents: [QuickSummarizer]`. + +## Remote and ESM Loading + +Load agents from external modules or remote URLs without importing them directly. + +**ESM loading** -- load an agent from an ES module: + +```typescript +const ExternalAgent = Agent.esm('@my-org/agents@^1.0.0', 'ExternalAgent', { + description: 'An agent loaded from an ES module', +}); +``` + +**Remote loading** -- load an agent from a remote URL: + +```typescript +const CloudAgent = Agent.remote('https://example.com/agents/cloud-agent', 'CloudAgent', { + description: 'An agent loaded from a remote server', +}); +``` + +Both return values that can be registered in `agents: [ExternalAgent, CloudAgent]`. + +## Registration + +Add agent classes (or function-style agents) to the `agents` array in `@FrontMcp` or `@App`. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'review-app', + agents: [PRReviewerAgent, CodeAuditorAgent], + tools: [HelperTool], +}) +class ReviewApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [ReviewApp], + agents: [QuickSummarizer], // can also register agents directly on the server +}) +class MyServer {} +``` + +## Nx Generator + +Scaffold a new agent using the Nx generator: + +```bash +nx generate @frontmcp/nx:agent +``` + +This creates the agent file, spec file, and updates barrel exports. + +## Rate Limiting, Concurrency, and Timeout + +Protect agents with throttling controls: + +```typescript +@Agent({ + name: 'expensive_agent', + description: 'An agent that performs expensive LLM operations', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + task: z.string(), + }, + rateLimit: { maxRequests: 5, windowMs: 60_000 }, + concurrency: { maxConcurrent: 1 }, + timeout: { executeMs: 120_000 }, + tags: ['expensive', 'llm'], +}) +class ExpensiveAgent extends AgentContext { + async execute(input: { task: string }) { + // At most 5 calls per minute, 1 concurrent, 2 minute timeout + return super.execute(input); + } +} +``` + +## Agent with Providers and Plugins + +Agents can include their own providers and plugins for self-contained dependency management: + +```typescript +@Agent({ + name: 'database_agent', + description: 'Agent that interacts with databases', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + query: z.string().describe('Natural language database query'), + }, + tools: [RunSqlTool, ListTablesTool, DescribeTableTool], + providers: [DatabaseProvider], + plugins: [RememberPlugin], + systemInstructions: + 'You have access to a database. List tables, describe schemas, and run SQL to answer the user query.', +}) +class DatabaseAgent extends AgentContext {} +``` + +## Agent with Resources and Prompts + +Agents can include resources and prompts that are available within the agent's scope: + +```typescript +@Agent({ + name: 'docs_agent', + description: 'Agent that manages documentation', + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + }, + inputSchema: { + topic: z.string().describe('Topic to document'), + }, + tools: [WriteFileTool, ReadFileTool], + resources: [DocsTemplateResource], + prompts: [TechnicalWritingPrompt], +}) +class DocsAgent extends AgentContext {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| LLM config | `llm: { provider: 'anthropic', model: '...', apiKey: { env: 'KEY' } }` | `llm: { provider: 'anthropic', apiKey: 'sk-hardcoded' }` | Environment variable references prevent leaking secrets in code | +| Inner tools vs exported | `tools: [...]` for agent-private; `exports: { tools: [...] }` for MCP-visible | Putting all tools in `tools` and expecting clients to see them | Inner tools are private to the agent; only exported tools appear in MCP listing | +| Custom execute | Override `execute()` for multi-pass orchestration | Putting all logic in system instructions | Custom `execute()` gives structured control over completion calls and stages | +| Sub-agents | Use `agents: [SubAgent]` for composition | Calling another agent's `execute()` directly | The `agents` array enables proper lifecycle, scope isolation, and handoff | +| Swarm handoff | Use `swarm.handoff` with `agent` name and `condition` | Manually routing between agents in `execute()` | Swarm config enables declarative, LLM-driven handoff between agents | + +## Verification Checklist + +### Configuration + +- [ ] Agent class extends `AgentContext` and has `@Agent` decorator with `name`, `description`, and `llm` +- [ ] `inputSchema` is defined with Zod raw shape for input validation +- [ ] Inner tools in `tools` array are valid `@Tool` classes +- [ ] Agent is registered in `agents` array of `@App` or `@FrontMcp` +- [ ] API key uses `{ env: 'VAR_NAME' }` pattern, not hardcoded strings + +### Runtime + +- [ ] Agent appears in MCP tool listing (agents surface as callable tools) +- [ ] LLM adapter connects successfully to the configured provider +- [ ] Inner tools are invoked correctly during the agent loop +- [ ] `this.completion()` and `this.streamCompletion()` return valid responses +- [ ] Swarm handoff transfers control to the correct specialist agent + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------- | +| Agent not appearing in tool listing | Not registered in `agents` array | Add agent class to `@App` or `@FrontMcp` `agents` array | +| LLM authentication error | API key not set or incorrect env variable | Verify the environment variable name in `apiKey: { env: '...' }` is set | +| Inner tools not being called | Tools not listed in `tools` array of `@Agent` | Add tool classes to the `tools` field in the `@Agent` decorator | +| Agent times out | No timeout or rate limit configured | Add `timeout: { executeMs: 120_000 }` and `rateLimit` to `@Agent` options | +| Swarm handoff fails | Target agent name does not match any registered agent | Ensure `handoff.agent` matches the `name` of a registered agent in the same scope | + +## Reference + +- [Agents Documentation](https://docs.agentfront.dev/frontmcp/servers/agents) +- Related skills: `create-tool`, `create-provider`, `create-prompt`, `create-resource` diff --git a/libs/skills/catalog/frontmcp-development/references/create-job.md b/libs/skills/catalog/frontmcp-development/references/create-job.md new file mode 100644 index 00000000..ab7aeef7 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-job.md @@ -0,0 +1,608 @@ +# Creating Jobs + +Jobs are long-running background tasks with built-in retry policies, progress tracking, and permission controls. Unlike tools (which execute synchronously within a request), jobs run asynchronously and persist their state across retries and restarts. + +## When to Use This Skill + +### Must Use + +- Running work that takes longer than a request cycle (ETL pipelines, large imports) +- Tasks that need automatic retry with exponential backoff on failure +- Background operations that must track and report progress over time + +### Recommended + +- Scheduled maintenance tasks or periodic data synchronization +- Operations requiring permission controls (role-based, scope-based access) +- Work that must persist state across retries and server restarts + +### Skip When + +- The work completes in a few seconds and needs no retry or progress tracking (see `create-tool`) +- You need to expose read-only data at a URI (see `create-resource`) +- The task requires autonomous LLM reasoning rather than a deterministic pipeline (see `create-agent`) + +> **Decision:** Use this skill when you need a long-running background task with retry policies, progress tracking, or permission controls. + +## Class-Based Pattern + +Create a class extending `JobContext` and implement the `execute(input: In): Promise` method. The `@Job` decorator requires `name`, `inputSchema`, and `outputSchema`. + +### JobMetadata Fields + +| Field | Type | Required | Default | Description | +| -------------- | ------------------------ | -------- | ---------------- | -------------------------------------- | +| `name` | `string` | Yes | -- | Unique job name | +| `inputSchema` | `ZodRawShape` | Yes | -- | Zod raw shape for input validation | +| `outputSchema` | `ZodRawShape \| ZodType` | Yes | -- | Zod schema for output validation | +| `description` | `string` | No | -- | Human-readable description | +| `timeout` | `number` | No | `300000` (5 min) | Maximum execution time in milliseconds | +| `retry` | `RetryPolicy` | No | -- | Retry configuration (see below) | +| `tags` | `string[]` | No | -- | Categorization tags | +| `labels` | `Record` | No | -- | Key-value labels for filtering | +| `permissions` | `JobPermissions` | No | -- | Access control configuration | + +### Basic Example + +```typescript +import { Job, JobContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Job({ + name: 'generate-report', + description: 'Generate a PDF report from data', + inputSchema: { + reportType: z.enum(['sales', 'inventory', 'users']).describe('Type of report'), + dateRange: z.object({ + from: z.string().describe('Start date (ISO 8601)'), + to: z.string().describe('End date (ISO 8601)'), + }), + format: z.enum(['pdf', 'csv']).default('pdf').describe('Output format'), + }, + outputSchema: { + url: z.string().url(), + pageCount: z.number().int(), + generatedAt: z.string(), + }, + timeout: 120000, +}) +class GenerateReportJob extends JobContext { + async execute(input: { + reportType: 'sales' | 'inventory' | 'users'; + dateRange: { from: string; to: string }; + format: 'pdf' | 'csv'; + }) { + this.log(`Starting ${input.reportType} report generation`); + + this.progress(10, 100, 'Fetching data'); + const data = await this.fetchReportData(input.reportType, input.dateRange); + + this.progress(50, 100, 'Generating document'); + const document = await this.buildDocument(data, input.format); + + this.progress(90, 100, 'Uploading'); + const url = await this.uploadDocument(document); + + this.progress(100, 100, 'Complete'); + return { + url, + pageCount: document.pages, + generatedAt: new Date().toISOString(), + }; + } + + private async fetchReportData(type: string, range: { from: string; to: string }) { + return { rows: [], count: 0 }; + } + private async buildDocument(data: unknown, format: string) { + return { pages: 5, buffer: Buffer.alloc(0) }; + } + private async uploadDocument(doc: { buffer: Buffer }) { + return 'https://storage.example.com/reports/report-001.pdf'; + } +} +``` + +## JobContext Methods and Properties + +`JobContext` extends `ExecutionContextBase` and adds job-specific capabilities: + +### Methods + +- `execute(input: In): Promise` -- the main method you implement. Receives validated input, must return a value matching `outputSchema`. +- `this.progress(pct: number, total?: number, msg?: string)` -- report progress. `pct` is the current value, `total` is the maximum (default 100), `msg` is an optional status message. +- `this.log(message: string)` -- append a log entry to the job's log. Persisted with the job state and retrievable after completion. +- `this.respond(value: Out)` -- explicitly set the job output. Alternatively, return the value from `execute()`. +- `this.getLogs(): string[]` -- retrieve all log entries recorded so far. +- `this.get(token)` -- resolve a dependency from DI (throws if not found). +- `this.tryGet(token)` -- resolve a dependency from DI (returns `undefined` if not found). +- `this.fail(err)` -- abort execution, triggers error flow (never returns). +- `this.mark(stage)` -- set the active execution stage for debugging/tracking. +- `this.fetch(input, init?)` -- HTTP fetch with context propagation. + +### Properties + +- `this.attempt` -- the current attempt number (1-based). On the first run, `this.attempt` is `1`. On the first retry, it is `2`, and so on. +- `this.input` -- the validated input object. +- `this.metadata` -- job metadata from the decorator. +- `this.scope` -- the current scope instance. + +## Retry Configuration + +Configure automatic retries with exponential backoff using the `retry` field. + +### RetryPolicy Fields + +| Field | Type | Default | Description | +| ------------------- | -------- | ------- | ---------------------------------------------------- | +| `maxAttempts` | `number` | `1` | Total number of attempts (including the initial run) | +| `backoffMs` | `number` | `1000` | Initial delay before the first retry in milliseconds | +| `backoffMultiplier` | `number` | `2` | Multiplier applied to backoff after each retry | +| `maxBackoffMs` | `number` | `30000` | Maximum backoff duration in milliseconds | + +### Example with Retry + +```typescript +@Job({ + name: 'sync-external-api', + description: 'Synchronize data from an external API', + inputSchema: { + endpoint: z.string().url().describe('API endpoint to sync from'), + batchSize: z.number().int().min(1).max(1000).default(100), + }, + outputSchema: { + synced: z.number().int(), + errors: z.number().int(), + }, + timeout: 600000, // 10 minutes + retry: { + maxAttempts: 5, + backoffMs: 2000, + backoffMultiplier: 2, + maxBackoffMs: 60000, + }, +}) +class SyncExternalApiJob extends JobContext { + async execute(input: { endpoint: string; batchSize: number }) { + this.log(`Attempt ${this.attempt}: syncing from ${input.endpoint}`); + + const response = await this.fetch(input.endpoint); + if (!response.ok) { + this.fail(new Error(`API returned ${response.status}`)); + } + + const data = await response.json(); + let synced = 0; + let errors = 0; + + for (let i = 0; i < data.items.length; i += input.batchSize) { + const batch = data.items.slice(i, i + input.batchSize); + this.progress(i, data.items.length, `Processing batch ${Math.floor(i / input.batchSize) + 1}`); + + try { + await this.processBatch(batch); + synced += batch.length; + } catch (err) { + errors += batch.length; + this.log(`Batch error: ${err}`); + } + } + + return { synced, errors }; + } + + private async processBatch(batch: unknown[]) { + // process batch + } +} +``` + +With this configuration, if the job fails: + +- Attempt 1: immediate execution +- Attempt 2: retry after 2000ms +- Attempt 3: retry after 4000ms +- Attempt 4: retry after 8000ms +- Attempt 5: retry after 16000ms + +The backoff is capped at `maxBackoffMs` (60000ms), so no delay exceeds 60 seconds. + +## Progress Tracking + +Use `this.progress(pct, total?, msg?)` to report job progress. The framework persists progress and makes it queryable. + +```typescript +@Job({ + name: 'import-csv', + description: 'Import records from a CSV file', + inputSchema: { + fileUrl: z.string().url(), + tableName: z.string(), + }, + outputSchema: { + imported: z.number().int(), + skipped: z.number().int(), + }, +}) +class ImportCsvJob extends JobContext { + async execute(input: { fileUrl: string; tableName: string }) { + this.progress(0, 100, 'Downloading file'); + const rows = await this.downloadAndParse(input.fileUrl); + + let imported = 0; + let skipped = 0; + + for (let i = 0; i < rows.length; i++) { + this.progress(i + 1, rows.length, `Importing row ${i + 1} of ${rows.length}`); + + try { + await this.insertRow(input.tableName, rows[i]); + imported++; + } catch { + skipped++; + } + } + + this.log(`Import complete: ${imported} imported, ${skipped} skipped`); + return { imported, skipped }; + } + + private async downloadAndParse(url: string) { + return []; + } + private async insertRow(table: string, row: unknown) { + /* insert */ + } +} +``` + +## Permissions + +Control who can interact with jobs using the `permissions` field. Permissions support action-based access, roles, scopes, and custom predicates. + +### Permission Actions + +| Action | Description | +| --------- | -------------------------- | +| `create` | Submit a new job | +| `read` | View job status and output | +| `update` | Modify a running job | +| `delete` | Cancel or remove a job | +| `execute` | Trigger job execution | +| `list` | List jobs matching filters | + +### Permission Configuration + +```typescript +@Job({ + name: 'data-export', + description: 'Export data to external storage', + inputSchema: { + dataset: z.string(), + destination: z.string().url(), + }, + outputSchema: { + exportedRows: z.number().int(), + location: z.string().url(), + }, + permissions: { + actions: ['create', 'read', 'execute', 'list'], + roles: ['admin', 'data-engineer'], + scopes: ['jobs:write', 'data:export'], + predicate: (ctx) => ctx.user?.department === 'engineering', + }, +}) +class DataExportJob extends JobContext { + async execute(input: { dataset: string; destination: string }) { + // Only users with the right roles, scopes, or matching the predicate can run this + this.log(`Exporting dataset: ${input.dataset}`); + const rows = await this.exportData(input.dataset, input.destination); + return { exportedRows: rows, location: input.destination }; + } + + private async exportData(dataset: string, destination: string) { + return 1000; + } +} +``` + +### Combining Permission Strategies + +Permissions fields are additive -- all specified conditions must be met: + +```typescript +permissions: { + // Actions this job supports + actions: ['create', 'read', 'execute', 'delete', 'list'], + + // Role-based: user must have one of these roles + roles: ['admin', 'operator'], + + // Scope-based: user token must include these scopes + scopes: ['jobs:manage'], + + // Custom predicate: arbitrary logic + predicate: (ctx) => { + const user = ctx.user; + return user?.isActive && user?.emailVerified; + }, +} +``` + +## Function Builder + +For simple jobs that do not need a class, use the `job()` function builder. The callback receives `(input, ctx)` where `ctx` provides all `JobContext` methods. + +```typescript +import { job } from '@frontmcp/sdk'; +import { z } from 'zod'; + +const CleanupTempFiles = job({ + name: 'cleanup-temp-files', + description: 'Remove temporary files older than the specified age', + inputSchema: { + directory: z.string().describe('Directory to clean'), + maxAgeDays: z.number().int().min(1).default(7), + }, + outputSchema: { + deleted: z.number().int(), + freedBytes: z.number().int(), + }, +})((input, ctx) => { + ctx.log(`Cleaning ${input.directory}, max age: ${input.maxAgeDays} days`); + ctx.progress(0, 100, 'Scanning directory'); + + // ... scan and delete logic ... + + ctx.progress(100, 100, 'Cleanup complete'); + return { deleted: 42, freedBytes: 1024000 }; +}); +``` + +Register it the same way as a class job: `jobs: [CleanupTempFiles]`. + +## Remote and ESM Loading + +Load jobs from external modules or remote URLs without importing them directly. + +**ESM loading** -- load a job from an ES module: + +```typescript +const ExternalJob = Job.esm('@my-org/jobs@^1.0.0', 'ExternalJob', { + description: 'A job loaded from an ES module', +}); +``` + +**Remote loading** -- load a job from a remote URL: + +```typescript +const CloudJob = Job.remote('https://example.com/jobs/cloud-job', 'CloudJob', { + description: 'A job loaded from a remote server', +}); +``` + +Both return values that can be registered in `jobs: [ExternalJob, CloudJob]`. + +## Registration and Configuration + +### Registering Jobs + +Add job classes (or function-style jobs) to the `jobs` array in `@App`. + +```typescript +import { App } from '@frontmcp/sdk'; + +@App({ + name: 'data-app', + jobs: [GenerateReportJob, SyncExternalApiJob, ImportCsvJob, CleanupTempFiles], +}) +class DataApp {} +``` + +### Enabling the Jobs System + +Jobs require a persistent store for state management. Enable the jobs system in `@FrontMcp` configuration. + +```typescript +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [DataApp], + jobs: { + enabled: true, + store: { + redis: { + provider: 'redis', + host: 'localhost', + port: 6379, + keyPrefix: 'mcp:jobs:', + }, + }, + }, +}) +class MyServer {} +``` + +The store persists job state, progress, logs, and outputs across retries and server restarts. Redis is recommended for production. Without `jobs.enabled: true`, registered jobs will not be activated. + +## Nx Generator + +Scaffold a new job using the Nx generator: + +```bash +nx generate @frontmcp/nx:job +``` + +This creates the job file, spec file, and updates barrel exports. + +## Complete Example: Data Pipeline Job + +```typescript +import { Job, JobContext, FrontMcp, App, job } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Job({ + name: 'etl-pipeline', + description: 'Extract, transform, and load data from source to warehouse', + inputSchema: { + source: z.string().url().describe('Source data URL'), + destination: z.string().describe('Destination table name'), + transformations: z + .array(z.enum(['normalize', 'deduplicate', 'validate', 'enrich'])) + .default(['normalize', 'validate']), + }, + outputSchema: { + extracted: z.number().int(), + transformed: z.number().int(), + loaded: z.number().int(), + errors: z.array( + z.object({ + row: z.number(), + message: z.string(), + }), + ), + duration: z.number(), + }, + timeout: 900000, // 15 minutes + retry: { + maxAttempts: 3, + backoffMs: 5000, + backoffMultiplier: 2, + maxBackoffMs: 30000, + }, + tags: ['etl', 'data-pipeline'], + labels: { team: 'data-engineering', priority: 'high' }, + permissions: { + actions: ['create', 'read', 'execute', 'list'], + roles: ['admin', 'data-engineer'], + scopes: ['jobs:execute', 'data:write'], + }, +}) +class EtlPipelineJob extends JobContext { + async execute(input: { + source: string; + destination: string; + transformations: ('normalize' | 'deduplicate' | 'validate' | 'enrich')[]; + }) { + const startTime = Date.now(); + const errors: { row: number; message: string }[] = []; + + this.log(`Attempt ${this.attempt}: Starting ETL pipeline`); + this.log(`Source: ${input.source}, Destination: ${input.destination}`); + + // Extract + this.progress(0, 100, 'Extracting data'); + this.mark('extract'); + const rawData = await this.extract(input.source); + this.log(`Extracted ${rawData.length} records`); + + // Transform + this.progress(33, 100, 'Applying transformations'); + this.mark('transform'); + let transformed = rawData; + for (const t of input.transformations) { + transformed = await this.applyTransformation(transformed, t, errors); + } + this.log(`Transformed ${transformed.length} records (${errors.length} errors)`); + + // Load + this.progress(66, 100, 'Loading into warehouse'); + this.mark('load'); + const loaded = await this.load(input.destination, transformed); + this.log(`Loaded ${loaded} records into ${input.destination}`); + + this.progress(100, 100, 'Pipeline complete'); + const logs = this.getLogs(); + this.log(`Total log entries: ${logs.length}`); + + return { + extracted: rawData.length, + transformed: transformed.length, + loaded, + errors, + duration: Date.now() - startTime, + }; + } + + private async extract(source: string): Promise { + return []; + } + private async applyTransformation( + data: unknown[], + type: string, + errors: { row: number; message: string }[], + ): Promise { + return data; + } + private async load(table: string, data: unknown[]): Promise { + return data.length; + } +} + +@App({ + name: 'data-app', + jobs: [EtlPipelineJob], +}) +class DataApp {} + +@FrontMcp({ + info: { name: 'data-server', version: '1.0.0' }, + apps: [DataApp], + jobs: { + enabled: true, + store: { + redis: { + provider: 'redis', + host: 'localhost', + port: 6379, + keyPrefix: 'mcp:jobs:', + }, + }, + }, +}) +class DataServer {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------- | ------------------------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------ | +| Progress tracking | `this.progress(50, 100, 'Processing batch 5')` | Not reporting progress | Progress is persisted and queryable; essential for long-running visibility | +| Retry config | `retry: { maxAttempts: 3, backoffMs: 2000, backoffMultiplier: 2 }` | Implementing retry logic manually in `execute()` | Framework handles retry with exponential backoff and attempt tracking | +| Attempt awareness | Check `this.attempt` for retry-specific logic | Ignoring attempt number | `this.attempt` is 1-based; use it to log retry context or adjust behavior | +| Job logging | `this.log('message')` for persistent, queryable logs | Using `console.log()` | `this.log()` persists with job state; `console.log` is ephemeral | +| Permissions | Use `permissions: { roles: [...], scopes: [...] }` declaratively | Checking roles manually inside `execute()` | Declarative permissions are enforced before execution and are self-documenting | + +## Verification Checklist + +### Configuration + +- [ ] Job class extends `JobContext` and implements `execute(input)` +- [ ] `@Job` decorator has `name`, `inputSchema`, and `outputSchema` +- [ ] `retry` policy is configured if the job may fail transiently +- [ ] `timeout` is set appropriately for the expected execution duration +- [ ] Job is registered in `jobs` array of `@App` + +### Runtime + +- [ ] `jobs.enabled: true` is set in `@FrontMcp` configuration with a store +- [ ] Job executes and returns output matching `outputSchema` +- [ ] Progress is reported and queryable during execution +- [ ] Retry fires with correct backoff delays on transient failures +- [ ] Permissions block unauthorized users before execution starts + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- | +| Job not activated | `jobs.enabled` not set to `true` in `@FrontMcp` | Add `jobs: { enabled: true, store: { ... } }` to `@FrontMcp` config | +| Job fails without retrying | No `retry` policy configured | Add `retry: { maxAttempts: 3, backoffMs: 2000 }` to `@Job` options | +| Progress not visible | Not calling `this.progress()` during execution | Add `this.progress(pct, total, message)` calls at each stage | +| Job times out unexpectedly | Default 5-minute timeout too short | Set `timeout` in `@Job` to a higher value (e.g., `600000` for 10 minutes) | +| Permission denied error | User lacks required roles or scopes | Verify user has one of the `roles` and all `scopes` defined in `permissions` | + +## Reference + +- [Jobs Documentation](https://docs.agentfront.dev/frontmcp/servers/jobs) +- Related skills: `create-tool`, `create-provider`, `create-agent`, `create-workflow` diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md new file mode 100644 index 00000000..b14d6ecf --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md @@ -0,0 +1,334 @@ +# Creating Plugins with Flow Lifecycle Hooks + +Plugins intercept and extend FrontMCP flows using lifecycle hook decorators. Every flow (tool calls, resource reads, prompt gets, etc.) is composed of **stages**, and hooks let you run logic before, after, around, or instead of any stage. + +## When to Use This Skill + +### Must Use + +- Adding before/after logic to tool execution (logging, metrics, input enrichment) +- Implementing authorization checks that intercept flows before they reach the tool +- Wrapping stage execution with caching, retry, or timing logic via `@Around` + +### Recommended + +- Replacing a built-in stage entirely with custom logic using `@Stage` +- Adding hooks directly on a `@Tool` class for tool-specific pre/post processing +- Filtering hook execution by tool name or context properties using `filter` predicates + +### Skip When + +- You need providers, context extensions, or contributed tools (see `create-plugin`) +- You want to use an existing official plugin that already provides hooks (see `official-plugins`) +- You are building a simple tool with no cross-cutting concerns (see `create-tool`) + +> **Decision:** Use this skill when you need to intercept or wrap flow stages with `@Will`, `@Did`, `@Around`, or `@Stage` decorators. + +## Hook Decorator Types + +FrontMCP provides four hook decorators obtained via `FlowHooksOf(flowName)`: + +| Decorator | Timing | Use Case | +| --------- | ---------------------------------- | ------------------------------------------- | +| `@Will` | **Before** a stage runs | Validate input, inject headers, check auth | +| `@Did` | **After** a stage completes | Log results, emit metrics, transform output | +| `@Stage` | **Replaces** a stage entirely | Custom execution, mock responses | +| `@Around` | **Wraps** a stage (before + after) | Caching, timing, retry logic | + +### FlowHooksOf API + +```typescript +import { FlowHooksOf } from '@frontmcp/sdk'; + +const { Stage, Will, Did, Around } = FlowHooksOf('tools:call-tool'); +``` + +`FlowHooksOf(flowName)` returns an object with all four decorator factories bound to the specified flow. + +## Available Flow Names + +| Flow Name | Description | +| -------------------------- | ------------------------ | +| `tools:call-tool` | Tool execution | +| `tools:list-tools` | Tool listing / discovery | +| `resources:read-resource` | Resource reading | +| `resources:list-resources` | Resource listing | +| `prompts:get-prompt` | Prompt retrieval | +| `prompts:list-prompts` | Prompt listing | +| `http:request` | HTTP request handling | +| `agents:call-agent` | Agent invocation | + +## Pre-Built Hook Type Exports + +For convenience, FrontMCP exports typed aliases so you do not need to call `FlowHooksOf` directly: + +```typescript +import { + ToolHook, // FlowHooksOf('tools:call-tool') + ListToolsHook, // FlowHooksOf('tools:list-tools') + ResourceHook, // FlowHooksOf('resources:read-resource') + ListResourcesHook, // FlowHooksOf('resources:list-resources') + AgentCallHook, // FlowHooksOf('agents:call-agent') + HttpHook, // FlowHooksOf('http:request') +} from '@frontmcp/sdk'; +``` + +Usage: + +```typescript +const { Will, Did, Around, Stage } = ToolHook; +``` + +## call-tool Flow Stages + +The `tools:call-tool` flow proceeds through these stages in order: + +1. **parseInput** - Parse raw input from the MCP request +2. **findTool** - Look up the tool in the registry +3. **checkToolAuthorization** - Verify the caller is authorized +4. **createToolCallContext** - Build the ToolContext instance +5. **validateInput** - Validate input against the Zod schema +6. **execute** - Run the tool's `execute()` method +7. **validateOutput** - Validate output against the output schema +8. **finalize** - Format and return the MCP response + +## HookOptions + +Both `@Will` and `@Did` (and `@Around`) accept an optional options object: + +```typescript +@Will('execute', { + priority: 10, // Higher runs first (default: 0) + filter: (ctx) => ctx.toolName !== 'health_check', // Predicate to skip +}) +``` + +- **priority** (`number`) - Execution order when multiple hooks target the same stage. Higher values run first. Default: `0`. +- **filter** (`(ctx) => boolean`) - A predicate that receives the flow context. Return `false` to skip this hook for the current invocation. + +## Examples + +### Logging Plugin + +```typescript +import { Plugin } from '@frontmcp/sdk'; +import { ToolHook } from '@frontmcp/sdk'; + +const { Will, Did } = ToolHook; + +@Plugin({ name: 'logging-plugin' }) +export class LoggingPlugin { + @Will('execute', { priority: 100 }) + logBefore(ctx) { + console.log(`[LOG] Tool "${ctx.toolName}" called with`, ctx.input); + } + + @Did('execute') + logAfter(ctx) { + console.log(`[LOG] Tool "${ctx.toolName}" completed in ${ctx.elapsed}ms`); + } +} +``` + +### Authorization Check Plugin + +```typescript +import { Plugin } from '@frontmcp/sdk'; +import { ToolHook } from '@frontmcp/sdk'; + +const { Will } = ToolHook; + +@Plugin({ name: 'auth-check-plugin' }) +export class AuthCheckPlugin { + @Will('checkToolAuthorization', { priority: 50 }) + enforceRole(ctx) { + const user = ctx.tryGet(UserToken); + if (!user || !user.roles.includes('admin')) { + ctx.fail('Unauthorized: admin role required'); + } + } +} +``` + +### Caching Plugin with @Around + +```typescript +import { Plugin } from '@frontmcp/sdk'; +import { ToolHook } from '@frontmcp/sdk'; + +const { Around } = ToolHook; + +@Plugin({ name: 'cache-plugin' }) +export class CachePlugin { + private cache = new Map(); + + @Around('execute', { priority: 90 }) + async cacheResults(ctx, next) { + const key = `${ctx.toolName}:${JSON.stringify(ctx.input)}`; + const cached = this.cache.get(key); + + if (cached && cached.expiry > Date.now()) { + return cached.data; + } + + const result = await next(); + + this.cache.set(key, { + data: result, + expiry: Date.now() + 60_000, + }); + + return result; + } +} +``` + +### Stage Replacement + +```typescript +import { Plugin } from '@frontmcp/sdk'; +import { ToolHook } from '@frontmcp/sdk'; + +const { Stage } = ToolHook; + +@Plugin({ name: 'mock-plugin' }) +export class MockPlugin { + @Stage('execute', { + filter: (ctx) => ctx.toolName === 'fetch_weather', + }) + mockWeather(ctx) { + return { content: [{ type: 'text', text: '72F and sunny' }] }; + } +} +``` + +## Registering Plugins + +Register plugins in your `@App` decorator: + +```typescript +import { App } from '@frontmcp/sdk'; +import { LoggingPlugin } from './plugins/logging.plugin'; +import { CachePlugin } from './plugins/cache.plugin'; + +@App({ + name: 'my-app', + plugins: [LoggingPlugin, CachePlugin], +}) +export class MyApp {} +``` + +Plugins are initialized in array order. Hook priority determines execution order within the same stage. + +## Using Hooks Inside a @Tool Class + +You can add hook methods directly on a `@Tool` class to intercept its own execution flow. The hooks apply only when **this tool** is called: + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +const { Will, Did } = ToolHook; + +@Tool({ + name: 'process_order', + description: 'Process a customer order', + inputSchema: { + orderId: z.string(), + amount: z.number(), + }, + outputSchema: { status: z.string(), receipt: z.string() }, +}) +class ProcessOrderTool extends ToolContext { + // Runs BEFORE execute — validate, enrich input, check preconditions + @Will('execute', { priority: 10 }) + async beforeExecute() { + const db = this.get(DB_TOKEN); + const order = await db.findOrder(this.input.orderId); + if (!order) { + this.fail(new Error(`Order ${this.input.orderId} not found`)); + } + if (order.status === 'completed') { + this.fail(new Error('Order already processed')); + } + this.mark('validated'); + } + + // Main execution + async execute(input: { orderId: string; amount: number }) { + const payment = this.get(PAYMENT_TOKEN); + const receipt = await payment.charge(input.orderId, input.amount); + return { status: 'completed', receipt: receipt.id }; + } + + // Runs AFTER execute — log, notify, cleanup + @Did('execute') + async afterExecute() { + const analytics = this.tryGet(ANALYTICS_TOKEN); + if (analytics) { + await analytics.track('order_processed', { + orderId: this.input.orderId, + amount: this.input.amount, + }); + } + } +} +``` + +### How Tool-Level Hooks Work + +- `@Will('execute')` on a tool class runs **before** the `execute()` method of that specific tool +- `@Did('execute')` runs **after** `execute()` completes successfully +- `@Will('validateInput')` runs before input validation — useful for input enrichment +- `@Did('validateOutput')` runs after output validation — useful for output transformation +- The hook has full access to `this` (the tool context) including `this.input`, `this.get()`, `this.fail()` + +### Available Stages for Tool Hooks + +``` +parseInput → findTool → checkToolAuthorization → createToolCallContext + → validateInput → execute → validateOutput → finalize +``` + +Any stage can have `@Will`, `@Did`, `@Stage`, or `@Around` hooks. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Hook decorator source | `const { Will, Did } = ToolHook;` or `FlowHooksOf('tools:call-tool')` | Importing `Will` directly from `@frontmcp/sdk` | Decorators must be bound to a specific flow via `FlowHooksOf` or pre-built exports | +| Hook priority | `@Will('execute', { priority: 100 })` for early hooks | Relying on array order without priority | Multiple hooks on the same stage need explicit priority; higher runs first | +| Around next() | `const result = await next(); return result;` | Forgetting to call `next()` in `@Around` | Omitting `next()` silently skips the wrapped stage and all downstream hooks | +| Filter predicate | `filter: (ctx) => ctx.toolName !== 'health_check'` | Checking tool name inside the hook body and returning early | Filters skip the hook cleanly; returning early may leave state inconsistent | +| Tool-level hooks | `@Will('execute')` on a `@Tool` class (scoped to that tool) | `@Will('execute')` on a `@Plugin` class expecting tool-scoped behavior | Plugin hooks fire for all tools; tool-level hooks fire only for that tool | + +## Verification Checklist + +### Configuration + +- [ ] Hook decorator is obtained from `FlowHooksOf(flowName)` or a pre-built export (e.g., `ToolHook`) +- [ ] Stage name matches an actual stage in the targeted flow (e.g., `execute`, `validateInput`) +- [ ] Plugin with hooks is registered in `plugins` array of `@App` or `@FrontMcp` + +### Runtime + +- [ ] `@Will` hook fires before the targeted stage +- [ ] `@Did` hook fires after the targeted stage completes +- [ ] `@Around` hook calls `next()` and the wrapped stage executes +- [ ] `@Stage` replacement returns a valid response for the flow +- [ ] Hook `filter` correctly skips invocations for excluded tools + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------- | +| Hook never fires | Plugin not registered in `plugins` array | Add plugin class to `@App` or `@FrontMcp` `plugins` array | +| Hook fires for wrong flow | Used wrong flow name in `FlowHooksOf` | Verify flow name matches (e.g., `'tools:call-tool'` not `'tool:call'`) | +| `@Around` skips the stage entirely | `next()` not called inside the around handler | Always `await next()` to execute the wrapped stage | +| Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; higher numbers execute first | +| `@Stage` replacement causes downstream errors | Return value shape does not match stage contract | Ensure the return matches what the next stage expects (e.g., MCP response format) | + +## Reference + +- [Plugin Hooks Documentation](https://docs.agentfront.dev/frontmcp/plugins/creating-plugins) +- Related skills: `create-plugin`, `official-plugins`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin.md b/libs/skills/catalog/frontmcp-development/references/create-plugin.md new file mode 100644 index 00000000..4756bd93 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin.md @@ -0,0 +1,341 @@ +# Create a FrontMCP Plugin + +This skill covers building custom plugins for FrontMCP and using all 6 official plugins. Plugins are modular units that extend server behavior through providers, context extensions, lifecycle hooks, and contributed tools/resources/prompts. + +## When to Use This Skill + +### Must Use + +- Adding cross-cutting behavior (logging, caching, auth) that applies across multiple tools +- Extending `ExecutionContextBase` with new properties accessible via `this.propertyName` in tools +- Contributing injectable providers that tools or other plugins depend on + +### Recommended + +- Building a configurable module with runtime options using the `DynamicPlugin` pattern +- Extending the `@Tool` decorator metadata with custom fields (e.g., audit, approval) +- Composing multiple related providers, hooks, and tools into a single installable unit + +### Skip When + +- You only need lifecycle hooks without providers or context extensions (see `create-plugin-hooks`) +- You want to use an existing official plugin (see `official-plugins`) +- You need to generate tools from an external API spec (see `create-adapter`) + +> **Decision:** Use this skill when you need a reusable module that bundles providers, context extensions, or contributed entries and registers them via `@Plugin`. + +## Plugin Decorator Signature + +```typescript +function Plugin(metadata: PluginMetadata): ClassDecorator; +``` + +The `PluginMetadata` interface: + +```typescript +interface PluginMetadata { + name: string; + id?: string; + description?: string; + providers?: ProviderType[]; + exports?: ProviderType[]; + plugins?: PluginType[]; + adapters?: AdapterType[]; + tools?: ToolType[]; + resources?: ResourceType[]; + prompts?: PromptType[]; + skills?: SkillType[]; + scope?: 'app' | 'server'; // default: 'app' + contextExtensions?: ContextExtension[]; +} + +interface ContextExtension { + property: string; + token: Token; + errorMessage?: string; +} +``` + +## DynamicPlugin Base Class + +For plugins that accept runtime configuration, extend `DynamicPlugin`: + +```typescript +abstract class DynamicPlugin { + static dynamicProviders?(options: any): readonly ProviderType[]; + static init(options: InitOptions): PluginReturn; + get(token: Reference): T; +} +``` + +- `TOptions` -- the resolved options type (after parsing/defaults) +- `TInput` -- the input type users provide to `init()` (may have optional fields) +- `init()` creates a provider entry for use in `plugins: [...]` arrays +- `dynamicProviders()` returns providers computed from the input options + +## Step 1: Create a Simple Plugin + +The minimal plugin only needs a name: + +```typescript +import { Plugin } from '@frontmcp/sdk'; + +@Plugin({ + name: 'audit-log', + description: 'Logs tool executions for audit compliance', +}) +export default class AuditLogPlugin {} +``` + +Register it in your server: + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; +import AuditLogPlugin from './plugins/audit-log.plugin'; + +@App({ name: 'MyApp' }) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + plugins: [AuditLogPlugin], + tools: [ + /* your tools */ + ], +}) +class MyServer {} +``` + +## Step 2: Add Providers + +Plugins contribute injectable services via `providers`: + +```typescript +import { Plugin, Provider } from '@frontmcp/sdk'; +import type { Token } from '@frontmcp/sdk'; + +export const AuditLoggerToken: Token = Symbol('AuditLogger'); + +@Provider() +class AuditLogger { + async logToolCall(toolName: string, userId: string, input: unknown): Promise { + console.log(`[AUDIT] ${userId} called ${toolName}`, input); + } +} + +@Plugin({ + name: 'audit-log', + description: 'Logs tool executions for audit compliance', + providers: [{ provide: AuditLoggerToken, useClass: AuditLogger }], + exports: [AuditLogger], +}) +export default class AuditLogPlugin {} +``` + +## Step 3: Add Context Extensions + +Context extensions add properties to `ExecutionContextBase` so tools access plugin services via `this.propertyName`. Two parts are required: + +### Part A: TypeScript Type Declaration (Module Augmentation) + +```typescript +// audit-log.context-extension.ts +import type { AuditLogger } from './audit-logger'; + +declare module '@frontmcp/sdk' { + interface ExecutionContextBase { + /** Audit logger provided by AuditLogPlugin */ + readonly auditLog: AuditLogger; + } +} +``` + +### Part B: Register via Plugin Metadata + +The SDK handles runtime installation when you declare `contextExtensions` in plugin metadata. Do not modify `ExecutionContextBase.prototype` directly. + +```typescript +import { Plugin } from '@frontmcp/sdk'; +import type { Token } from '@frontmcp/sdk'; +import './audit-log.context-extension'; // Import for type augmentation side effect + +export const AuditLoggerToken: Token = Symbol('AuditLogger'); + +@Plugin({ + name: 'audit-log', + description: 'Logs tool executions for audit compliance', + providers: [{ provide: AuditLoggerToken, useClass: AuditLogger }], + contextExtensions: [ + { + property: 'auditLog', + token: AuditLoggerToken, + errorMessage: 'AuditLogPlugin is not installed. Add it to your @FrontMcp plugins array.', + }, + ], +}) +export default class AuditLogPlugin {} +``` + +Now tools can use `this.auditLog`: + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; + +@Tool({ name: 'delete_record' }) +class DeleteRecordTool extends ToolContext { + async execute(input: { recordId: string }) { + await this.auditLog.logToolCall('delete_record', this.scope.userId, input); + return { deleted: true }; + } +} +``` + +## Step 4: Create a Configurable Plugin with DynamicPlugin + +For plugins that accept runtime options, extend `DynamicPlugin`: + +```typescript +import { Plugin, DynamicPlugin, ProviderType } from '@frontmcp/sdk'; +import type { Token } from '@frontmcp/sdk'; + +export interface MyPluginOptions { + endpoint: string; + refreshIntervalMs: number; +} + +export type MyPluginOptionsInput = Omit & { + refreshIntervalMs?: number; +}; + +export const MyServiceToken: Token = Symbol('MyService'); + +@Plugin({ + name: 'my-plugin', + description: 'A configurable plugin', + contextExtensions: [ + { + property: 'myService', + token: MyServiceToken, + errorMessage: 'MyPlugin is not installed.', + }, + ], +}) +export default class MyPlugin extends DynamicPlugin { + options: MyPluginOptions; + + constructor(options: MyPluginOptionsInput = { endpoint: '' }) { + super(); + this.options = { refreshIntervalMs: 30_000, ...options }; + } + + static override dynamicProviders(options: MyPluginOptionsInput): ProviderType[] { + return [ + { + provide: MyServiceToken, + useFactory: () => + new MyService({ + refreshIntervalMs: 30_000, + ...options, + }), + }, + ]; + } +} +``` + +Register with `init()`: + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + plugins: [ + MyPlugin.init({ + endpoint: 'https://api.example.com', + refreshIntervalMs: 60_000, + }), + ], +}) +class MyServer {} +``` + +## Step 5: Extend Tool Metadata + +Plugins can add fields to the `@Tool` decorator via global augmentation: + +```typescript +declare global { + interface ExtendFrontMcpToolMetadata { + audit?: { + enabled: boolean; + level: 'info' | 'warn' | 'critical'; + }; + } +} +``` + +Tools then use it: + +```typescript +@Tool({ + name: 'delete_user', + audit: { enabled: true, level: 'critical' }, +}) +class DeleteUserTool extends ToolContext { + /* ... */ +} +``` + +--- + +## Official Plugins + +For official plugin installation, configuration, and examples, see the **official-plugins** skill. FrontMCP provides 6 official plugins: CodeCall, Remember, Approval, Cache, Feature Flags, and Dashboard. Install individually or via `@frontmcp/plugins` (meta-package). + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------------------ | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata | `Object.defineProperty(ExecutionContextBase.prototype, ...)` manually | SDK handles runtime installation; manual modification causes ordering issues | +| Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file | Skipping the augmentation and casting `this` in tools | Without augmentation, TypeScript cannot type-check `this.auditLog` | +| Provider types | `Token = Symbol('AuditLogger')` with typed token | `provide: Symbol('AuditLogger')` without type annotation | Typed tokens enable compile-time DI resolution checking | +| Plugin scope | `scope: 'app'` (default) for app-scoped behavior | `scope: 'server'` when hooks should only apply to one app | Server scope fires hooks for all apps in a gateway; default to app | +| Dynamic options | Extend `DynamicPlugin` with `static dynamicProviders()` | Constructing providers in the constructor body | `dynamicProviders` runs before instantiation, enabling proper DI wiring | + +## Verification Checklist + +### Configuration + +- [ ] `@Plugin` decorator has `name` and `description` +- [ ] Providers are listed in `providers` array with typed tokens +- [ ] Exported providers are listed in `exports` array +- [ ] Context extensions have `property`, `token`, and `errorMessage` fields + +### Type Safety + +- [ ] Module augmentation file exists with `declare module '@frontmcp/sdk'` block +- [ ] Augmented properties are `readonly` on `ExecutionContextBase` +- [ ] Augmentation file is imported (side-effect import) in the plugin module + +### Runtime + +- [ ] Plugin is registered in `plugins` array of `@FrontMcp` or `@App` +- [ ] `this.propertyName` resolves correctly in tool contexts +- [ ] Missing plugin produces a clear error message (from `errorMessage`) +- [ ] Dynamic plugin options are validated in `dynamicProviders()` + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- | +| `this.auditLog` has type `any` or is unrecognized | Module augmentation file not imported | Add side-effect import: `import './audit-log.context-extension'` in plugin file | +| Circular dependency error at startup | Calling `installExtension()` at module top level | Remove manual installation; use `contextExtensions` metadata array instead | +| Provider not found in tool context | Provider not listed in plugin `exports` | Add the provider to both `providers` and `exports` arrays | +| Hooks fire for unrelated apps in gateway | Plugin `scope` set to `'server'` | Change to `scope: 'app'` (default) unless server-wide behavior is intended | +| `DynamicPlugin.init()` options ignored | Overriding constructor without calling `super()` | Ensure constructor calls `super()` and merges defaults properly | + +## Reference + +- [Plugin System Documentation](https://docs.agentfront.dev/frontmcp/plugins/creating-plugins) +- Related skills: `create-plugin-hooks`, `official-plugins`, `create-adapter`, `create-provider` diff --git a/libs/skills/catalog/frontmcp-development/references/create-prompt.md b/libs/skills/catalog/frontmcp-development/references/create-prompt.md new file mode 100644 index 00000000..e219e630 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-prompt.md @@ -0,0 +1,436 @@ +# Creating MCP Prompts + +Prompts define reusable AI interaction patterns in the MCP protocol. They produce structured message sequences that clients use to guide LLM conversations. In FrontMCP, prompts are classes extending `PromptContext`, decorated with `@Prompt`, that return `GetPromptResult` objects. + +## When to Use This Skill + +### Must Use + +- Building a reusable conversation template that AI clients invoke with arguments +- Defining structured multi-turn message sequences (user/assistant patterns) +- Creating domain-specific prompt patterns (code review, debugging, RAG queries) + +### Recommended + +- Standardizing message formats across multiple tools or agents +- Embedding MCP resource content into prompt messages for context +- Generating dynamic prompts that perform async lookups (knowledge base, APIs) + +### Skip When + +- You need an executable action that performs work and returns results (see `create-tool`) +- You need to expose read-only data at a URI (see `create-resource`) +- The task requires autonomous multi-step reasoning with inner tools (see `create-agent`) + +> **Decision:** Use this skill when you need a reusable, parameterized conversation template that produces structured `GetPromptResult` messages. + +## Class-Based Pattern + +Create a class extending `PromptContext` and implement `execute(args)`. The `@Prompt` decorator accepts `name`, optional `description`, and `arguments` (the prompt's input parameters). + +```typescript +import { Prompt, PromptContext } from '@frontmcp/sdk'; +import { GetPromptResult } from '@frontmcp/protocol'; + +@Prompt({ + name: 'code-review', + description: 'Generate a structured code review for the given code', + arguments: [ + { name: 'code', description: 'The code to review', required: true }, + { name: 'language', description: 'Programming language', required: false }, + ], +}) +class CodeReviewPrompt extends PromptContext { + async execute(args: Record): Promise { + const language = args.language ?? 'unknown language'; + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please review the following ${language} code. Focus on correctness, performance, and maintainability.\n\n\`\`\`${language}\n${args.code}\n\`\`\``, + }, + }, + ], + }; + } +} +``` + +### Decorator Options + +The `@Prompt` decorator accepts: + +- `name` (required) -- unique prompt name +- `description` (optional) -- human-readable description +- `arguments` (optional) -- array of `PromptArgument` objects + +### PromptArgument Structure + +Each entry in the `arguments` array has this shape: + +```typescript +interface PromptArgument { + name: string; // argument name + description?: string; // human-readable description + required?: boolean; // whether the argument must be provided (default: false) +} +``` + +Required arguments are validated before `execute()` runs. Missing required arguments throw `MissingPromptArgumentError`. + +### GetPromptResult Structure + +The `execute()` method must return a `GetPromptResult`: + +```typescript +interface GetPromptResult { + messages: Array<{ + role: 'user' | 'assistant'; + content: { + type: 'text'; + text: string; + }; + }>; +} +``` + +Messages use two roles: + +- `user` -- represents the human side of the conversation +- `assistant` -- primes the conversation with expected response patterns + +### Available Context Methods and Properties + +`PromptContext` extends `ExecutionContextBase`, providing: + +**Methods:** + +- `execute(args)` -- the main method you implement, receives `Record` +- `this.get(token)` -- resolve a dependency from DI (throws if not found) +- `this.tryGet(token)` -- resolve a dependency from DI (returns `undefined` if not found) +- `this.fail(err)` -- abort execution, triggers error flow (never returns) +- `this.mark(stage)` -- set active execution stage for debugging/tracking +- `this.fetch(input, init?)` -- HTTP fetch with context propagation + +**Properties:** + +- `this.metadata` -- prompt metadata from the decorator +- `this.scope` -- the current scope instance +- `this.context` -- the execution context + +## Multi-Turn Conversations + +Use `assistant` role messages to prime the conversation with expected response patterns: + +```typescript +@Prompt({ + name: 'debug-session', + description: 'Start a structured debugging session', + arguments: [ + { name: 'error', description: 'The error message or stack trace', required: true }, + { name: 'context', description: 'Additional context about what was happening', required: false }, + ], +}) +class DebugSessionPrompt extends PromptContext { + async execute(args: Record): Promise { + const contextNote = args.context ? `\n\nAdditional context: ${args.context}` : ''; + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `I encountered an error and need help debugging it.\n\nError:\n\`\`\`\n${args.error}\n\`\`\`${contextNote}`, + }, + }, + { + role: 'assistant', + content: { + type: 'text', + text: "I'll help you debug this. Let me analyze the error systematically.\n\n**Step 1: Error Classification**\nLet me first identify what type of error this is and its likely root cause.\n\n", + }, + }, + { + role: 'user', + content: { + type: 'text', + text: 'Please continue with your analysis and suggest specific fixes.', + }, + }, + ], + }; + } +} +``` + +## Dynamic Prompt Generation + +Prompts can perform async operations to generate context-aware messages. Use `this.get()` for dependency injection and `this.fetch()` for HTTP requests. + +```typescript +import type { Token } from '@frontmcp/di'; + +interface KnowledgeBase { + search(query: string, limit: number): Promise>; +} +const KNOWLEDGE_BASE: Token = Symbol('knowledge-base'); + +@Prompt({ + name: 'rag-query', + description: 'Answer a question using knowledge base context', + arguments: [ + { name: 'question', description: 'The question to answer', required: true }, + { name: 'maxSources', description: 'Maximum number of sources to include', required: false }, + ], +}) +class RagQueryPrompt extends PromptContext { + async execute(args: Record): Promise { + const kb = this.get(KNOWLEDGE_BASE); + const maxSources = parseInt(args.maxSources ?? '3', 10); + const sources = await kb.search(args.question, maxSources); + + const contextBlock = sources.map((s, i) => `### Source ${i + 1}: ${s.title}\n${s.content}`).join('\n\n'); + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Answer the following question using only the provided sources. If the sources do not contain enough information, say so clearly.\n\n**Question:** ${args.question}\n\n---\n\n${contextBlock}`, + }, + }, + ], + }; + } +} +``` + +## Embedding Resources in Prompts + +Include MCP resource content directly in prompt messages using the `resource` content type: + +```typescript +@Prompt({ + name: 'analyze-config', + description: 'Analyze application configuration and suggest improvements', + arguments: [{ name: 'configUri', description: 'URI of the config resource to analyze', required: true }], +}) +class AnalyzeConfigPrompt extends PromptContext { + async execute(args: Record): Promise { + return { + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: args.configUri, + mimeType: 'application/json', + text: '(resource content will be resolved by the client)', + }, + }, + }, + { + role: 'user', + content: { + type: 'text', + text: 'Analyze the configuration above. Identify potential issues, security concerns, and suggest improvements.', + }, + }, + ], + }; + } +} +``` + +## Function-Style Builder + +For simple prompts that do not need a class, use the `prompt()` function builder: + +```typescript +import { prompt } from '@frontmcp/sdk'; +import { GetPromptResult } from '@frontmcp/protocol'; + +const TranslatePrompt = prompt({ + name: 'translate', + description: 'Translate text between languages', + arguments: [ + { name: 'text', description: 'Text to translate', required: true }, + { name: 'from', description: 'Source language', required: true }, + { name: 'to', description: 'Target language', required: true }, + ], +})( + (args): GetPromptResult => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Translate the following text from ${args?.from} to ${args?.to}. Provide only the translation, no explanations.\n\nText: ${args?.text}`, + }, + }, + ], + }), +); +``` + +Register it the same way as a class prompt: `prompts: [TranslatePrompt]`. + +## Error Handling + +Use `this.fail()` to abort prompt execution. Missing required arguments are caught automatically before `execute()` runs. + +```typescript +@Prompt({ + name: 'generate-tests', + description: 'Generate test cases for a function', + arguments: [ + { name: 'functionCode', description: 'The function to test', required: true }, + { name: 'framework', description: 'Test framework (jest, mocha, vitest)', required: true }, + ], +}) +class GenerateTestsPrompt extends PromptContext { + async execute(args: Record): Promise { + const validFrameworks = ['jest', 'mocha', 'vitest']; + if (!validFrameworks.includes(args.framework)) { + this.fail(new Error(`Unsupported test framework: "${args.framework}". Supported: ${validFrameworks.join(', ')}`)); + } + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Write comprehensive ${args.framework} test cases for the following function. Include edge cases, error handling, and boundary conditions.\n\n\`\`\`\n${args.functionCode}\n\`\`\``, + }, + }, + ], + }; + } +} +``` + +## Stage Tracking + +Use `this.mark()` for debugging and observability in complex prompts: + +```typescript +@Prompt({ + name: 'research-report', + description: 'Generate a structured research report prompt', + arguments: [ + { name: 'topic', description: 'Research topic', required: true }, + { name: 'depth', description: 'Report depth: brief, standard, or comprehensive', required: false }, + ], +}) +class ResearchReportPrompt extends PromptContext { + async execute(args: Record): Promise { + this.mark('build-outline'); + const depth = args.depth ?? 'standard'; + const outline = this.buildOutline(depth); + + this.mark('compose-messages'); + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Write a ${depth} research report on "${args.topic}".\n\nFollow this structure:\n${outline}\n\nInclude citations where possible and maintain an objective, academic tone.`, + }, + }, + ], + }; + } + + private buildOutline(depth: string): string { + const sections = ['Introduction', 'Background', 'Key Findings']; + if (depth === 'standard' || depth === 'comprehensive') { + sections.push('Analysis', 'Discussion'); + } + if (depth === 'comprehensive') { + sections.push('Methodology', 'Limitations', 'Future Research'); + } + sections.push('Conclusion'); + return sections.map((s, i) => `${i + 1}. ${s}`).join('\n'); + } +} +``` + +## Registration + +Add prompt classes (or function-style prompts) to the `prompts` array in `@App`. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'my-app', + prompts: [CodeReviewPrompt, DebugSessionPrompt, TranslatePrompt], +}) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], +}) +class MyServer {} +``` + +## Nx Generator + +Scaffold a new prompt using the Nx generator: + +```bash +nx generate @frontmcp/nx:prompt +``` + +This creates the prompt file, spec file, and updates barrel exports. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------- | ----------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------- | +| Return type | `execute()` returns `Promise` | Returning a plain string or array of strings | MCP protocol requires `{ messages: [...] }` structure | +| Argument validation | Mark arguments as `required: true` in `arguments` array | Manually checking `args.field` inside `execute()` | Framework validates required arguments before `execute()` runs | +| Multi-turn priming | Use `assistant` role messages to prime expected response patterns | Putting all instructions in a single `user` message | Alternating roles guides the LLM toward structured output | +| Resource embedding | Use `type: 'resource'` content with a resource URI | Inlining resource data as raw text in the prompt | Resource references let clients resolve content dynamically | +| Error handling | Use `this.fail(err)` for validation failures in execute | `throw new Error(...)` directly | `this.fail` triggers the error flow with proper MCP error propagation | + +## Verification Checklist + +### Configuration + +- [ ] Prompt class extends `PromptContext` and implements `execute(args)` +- [ ] `@Prompt` decorator has `name` and `arguments` array with correct `required` flags +- [ ] Prompt is registered in `prompts` array of `@App` or `@FrontMcp` +- [ ] All required arguments have `required: true` + +### Runtime + +- [ ] Prompt appears in `prompts/list` MCP response +- [ ] Calling prompt with valid arguments returns well-formed `GetPromptResult` +- [ ] Missing required arguments trigger `MissingPromptArgumentError` +- [ ] Multi-turn messages have correct `user`/`assistant` role alternation +- [ ] DI dependencies resolve correctly via `this.get()` + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Prompt not appearing in `prompts/list` | Not registered in `prompts` array | Add prompt class to `@App` or `@FrontMcp` `prompts` array | +| `MissingPromptArgumentError` on optional argument | Argument marked `required: true` incorrectly | Set `required: false` for optional arguments in the `arguments` array | +| LLM ignores priming messages | Only using `user` role messages | Add `assistant` role messages to prime the conversation pattern | +| Type error on `execute()` return | Returning plain string instead of `GetPromptResult` | Wrap return in `{ messages: [{ role: 'user', content: { type: 'text', text: '...' } }] }` | +| `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` | + +## Reference + +- [Prompts Documentation](https://docs.agentfront.dev/frontmcp/servers/prompts) +- Related skills: `create-tool`, `create-resource`, `create-agent`, `create-provider` diff --git a/libs/skills/catalog/frontmcp-development/references/create-provider.md b/libs/skills/catalog/frontmcp-development/references/create-provider.md new file mode 100644 index 00000000..c8b5ec7b --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-provider.md @@ -0,0 +1,268 @@ +# Creating Providers (Dependency Injection) + +Providers are singleton services — database pools, API clients, config objects — that tools, resources, prompts, and agents can access via `this.get(token)`. + +## When to Use This Skill + +### Must Use + +- Multiple tools, resources, or agents need a shared database connection pool +- API clients or external service connections must be singleton (not recreated per request) +- You need lifecycle management with `onInit()` at startup and `onDestroy()` at shutdown + +### Recommended + +- Centralizing configuration values as a type-safe injectable object +- Sharing a cache layer (Map, Redis) across all execution contexts +- Providing environment-specific settings (API URLs, feature flags) via DI + +### Skip When + +- The service is only used by a single tool and has no lifecycle (inline it in the tool) +- You need to build an executable action for AI clients (see `create-tool`) +- You need autonomous LLM-driven orchestration (see `create-agent`) + +> **Decision:** Use this skill when you need a shared, singleton service with lifecycle management that tools, resources, and agents access via `this.get(token)`. + +## Step 1: Define a Token + +Tokens identify providers in the DI container: + +```typescript +import type { Token } from '@frontmcp/di'; + +// Define a typed token +interface DatabaseService { + query(sql: string, params?: unknown[]): Promise; + close(): Promise; +} + +const DB_TOKEN: Token = Symbol('DatabaseService'); +``` + +## Step 2: Create the Provider + +```typescript +import { Provider } from '@frontmcp/sdk'; +import { createPool, Pool } from 'your-db-driver'; + +@Provider({ name: 'DatabaseProvider' }) +class DatabaseProvider implements DatabaseService { + private pool!: Pool; + + async onInit() { + // Called once when server starts + this.pool = await createPool({ + connectionString: process.env.DATABASE_URL, + max: 20, + }); + } + + async query(sql: string, params?: unknown[]) { + return this.pool.query(sql, params); + } + + async onDestroy() { + // Called when server shuts down + await this.pool.end(); + } +} +``` + +## Step 3: Register in @App or @FrontMcp + +```typescript +@App({ + name: 'MyApp', + providers: [DatabaseProvider], // App-scoped provider + tools: [QueryTool, InsertTool], +}) +class MyApp {} + +// OR at server level (shared across all apps) +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + providers: [DatabaseProvider], // Server-scoped provider +}) +class Server {} +``` + +## Step 4: Use in Tools + +Access providers via `this.get(token)` in any context (ToolContext, ResourceContext, PromptContext, AgentContext): + +```typescript +@Tool({ + name: 'query_users', + description: 'Query users from the database', + inputSchema: { + filter: z.string().optional(), + limit: z.number().default(10), + }, + outputSchema: { + users: z.array(z.object({ id: z.string(), name: z.string(), email: z.string() })), + }, +}) +class QueryUsersTool extends ToolContext { + async execute(input: { filter?: string; limit: number }) { + const db = this.get(DB_TOKEN); // Get the database provider + const users = await db.query('SELECT id, name, email FROM users WHERE name LIKE $1 LIMIT $2', [ + `%${input.filter ?? ''}%`, + input.limit, + ]); + return { users }; + } +} +``` + +### Safe Access + +```typescript +// Throws if not registered +const db = this.get(DB_TOKEN); + +// Returns undefined if not registered +const db = this.tryGet(DB_TOKEN); +if (!db) { + this.fail(new Error('Database not configured')); +} +``` + +## Common Provider Patterns + +### Configuration Provider + +```typescript +interface AppConfig { + apiBaseUrl: string; + maxRetries: number; + debug: boolean; +} + +const CONFIG_TOKEN: Token = Symbol('AppConfig'); + +@Provider({ name: 'ConfigProvider' }) +class ConfigProvider implements AppConfig { + readonly apiBaseUrl = process.env.API_BASE_URL ?? 'https://api.example.com'; + readonly maxRetries = Number(process.env.MAX_RETRIES ?? 3); + readonly debug = process.env.DEBUG === 'true'; +} +``` + +### HTTP API Client Provider + +```typescript +interface ApiClient { + get(path: string): Promise; + post(path: string, body: unknown): Promise; +} + +const API_TOKEN: Token = Symbol('ApiClient'); + +@Provider({ name: 'ApiClientProvider' }) +class ApiClientProvider implements ApiClient { + private baseUrl!: string; + private apiKey!: string; + + async onInit() { + this.baseUrl = process.env.API_URL!; + this.apiKey = process.env.API_KEY!; + } + + async get(path: string) { + const res = await fetch(`${this.baseUrl}${path}`, { + headers: { Authorization: `Bearer ${this.apiKey}` }, + }); + return res.json(); + } + + async post(path: string, body: unknown) { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json(); + } +} +``` + +### Cache Provider + +```typescript +const CACHE_TOKEN: Token> = Symbol('Cache'); + +@Provider({ name: 'CacheProvider' }) +class CacheProvider extends Map { + // Map is already a valid provider - no lifecycle needed +} +``` + +## Provider Lifecycle + +| Method | When Called | Use For | +| ------------- | ----------------------- | -------------------------------- | +| `onInit()` | Server startup (async) | Open connections, load config | +| `onDestroy()` | Server shutdown (async) | Close connections, flush buffers | + +Providers are initialized in dependency order — if Provider A depends on Provider B, B initializes first. + +## Nx Generator + +```bash +nx generate @frontmcp/nx:provider my-provider --project=my-app +``` + +## Verification + +```bash +# Start server — providers initialize on startup +frontmcp dev + +# Call a tool that uses the provider +# If provider fails to init, you'll see an error at startup +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | ---------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- | +| Token definition | `const DB: Token = Symbol('DbService')` (typed Symbol) | `const DB = 'database'` (string literal) | Typed `Token` enables compile-time type checking on `this.get()` | +| DI resolution | `this.get(TOKEN)` with error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear `DependencyNotFoundError`; non-null assertions hide failures | +| Lifecycle | Use `onInit()` for async setup, `onDestroy()` for cleanup | Initializing connections in the constructor | Constructor runs synchronously; `onInit()` supports async operations | +| Registration scope | Register at `@App` level for app-scoped, `@FrontMcp` for server-scoped | Registering same provider in multiple apps | Server-scoped providers are shared; duplicating causes multiple instances | +| Config provider | `readonly` properties from `process.env` | Mutable properties that change at runtime | Providers are singletons; mutable state can cause race conditions | + +## Verification Checklist + +### Configuration + +- [ ] Provider class has `@Provider` decorator with `name` +- [ ] Token is defined with `Token` using a `Symbol` and typed interface +- [ ] Provider is registered in `providers` array of `@App` or `@FrontMcp` +- [ ] `onInit()` handles async setup (DB connections, API clients) +- [ ] `onDestroy()` cleans up resources (close connections, flush buffers) + +### Runtime + +- [ ] Server starts without provider initialization errors +- [ ] `this.get(TOKEN)` resolves the provider in tools, resources, and agents +- [ ] Provider is a singleton (same instance across all contexts) +- [ ] Server shutdown calls `onDestroy()` and cleans up resources +- [ ] Missing provider throws `DependencyNotFoundError` with a clear message + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------------- | +| `DependencyNotFoundError` at runtime | Provider not registered in scope | Add provider to `providers` array in `@App` or `@FrontMcp` | +| Provider `onInit()` fails at startup | Missing environment variable or unreachable service | Check environment variables and service connectivity before starting | +| Multiple instances of same provider | Registered in multiple apps instead of server level | Move to `@FrontMcp` `providers` for shared, server-scoped access | +| Type mismatch on `this.get(TOKEN)` | Token typed with wrong interface | Ensure `Token` generic matches the provider's implemented interface | +| Provider not destroyed on shutdown | Missing `onDestroy()` method | Implement `onDestroy()` to close connections and release resources | + +## Reference + +- [Providers Documentation](https://docs.agentfront.dev/frontmcp/extensibility/providers) +- Related skills: `create-tool`, `create-resource`, `create-agent`, `create-prompt` diff --git a/libs/skills/catalog/frontmcp-development/references/create-resource.md b/libs/skills/catalog/frontmcp-development/references/create-resource.md new file mode 100644 index 00000000..e84e0e58 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-resource.md @@ -0,0 +1,469 @@ +# Creating MCP Resources + +Resources expose data to AI clients through URI-based access following the MCP protocol. FrontMCP supports two kinds: **static resources** with fixed URIs (`@Resource`) and **resource templates** with parameterized URI patterns (`@ResourceTemplate`). + +## When to Use This Skill + +### Must Use + +- Exposing data to AI clients through URI-based access following the MCP protocol +- Serving dynamic or static content that clients read on demand (config, status, files) +- Creating parameterized URI patterns for families of related data (user profiles, repo files) + +### Recommended + +- Providing binary assets (images, PDFs) to AI clients via base64 blob encoding +- Centralizing read-only data sources that multiple tools or prompts reference +- Replacing ad-hoc tool responses with structured, cacheable resource URIs + +### Skip When + +- The client needs to perform an action, not read data (see `create-tool`) +- You are building a reusable conversation template (see `create-prompt`) +- The data requires autonomous multi-step reasoning to produce (see `create-agent`) + +> **Decision:** Use this skill when you need to expose readable data at a URI -- choose `@Resource` for a fixed URI or `@ResourceTemplate` for parameterized URI patterns. + +## Static Resources with @Resource + +### Decorator Options + +The `@Resource` decorator accepts: + +- `name` (required) -- unique resource name +- `uri` (required) -- static URI with a valid scheme per RFC 3986 +- `description` (optional) -- human-readable description +- `mimeType` (optional) -- MIME type of the resource content + +### Class-Based Pattern + +Create a class extending `ResourceContext` and implement `execute(uri, params)`. It must return a `ReadResourceResult`. + +```typescript +import { Resource, ResourceContext } from '@frontmcp/sdk'; +import { ReadResourceResult } from '@frontmcp/protocol'; + +@Resource({ + name: 'app-config', + uri: 'config://app/settings', + description: 'Current application configuration', + mimeType: 'application/json', +}) +class AppConfigResource extends ResourceContext { + async execute(uri: string, params: Record): Promise { + const config = { + version: '2.1.0', + environment: 'production', + features: { darkMode: true, notifications: true }, + }; + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(config, null, 2), + }, + ], + }; + } +} +``` + +### ReadResourceResult Structure + +The `ReadResourceResult` returned by `execute()` has this shape: + +```typescript +interface ReadResourceResult { + contents: Array<{ + uri: string; + mimeType?: string; + text?: string; // string content + blob?: string; // base64-encoded binary content + }>; +} +``` + +Each content item has a `uri`, optional `mimeType`, and either `text` (string data) or `blob` (base64 binary data). + +### Available Context Methods and Properties + +`ResourceContext` extends `ExecutionContextBase`, providing: + +**Methods:** + +- `execute(uri, params)` -- the main method you implement +- `this.get(token)` -- resolve a dependency from DI (throws if not found) +- `this.tryGet(token)` -- resolve a dependency from DI (returns `undefined` if not found) +- `this.fail(err)` -- abort execution, triggers error flow (never returns) +- `this.mark(stage)` -- set active execution stage for debugging/tracking +- `this.fetch(input, init?)` -- HTTP fetch with context propagation + +**Properties:** + +- `this.metadata` -- resource metadata from the decorator +- `this.scope` -- the current scope instance +- `this.context` -- the execution context + +### Simplified Return Values + +FrontMCP automatically normalizes common return shapes into valid `ReadResourceResult` format: + +```typescript +@Resource({ + name: 'server-status', + uri: 'status://server', + mimeType: 'application/json', +}) +class ServerStatusResource extends ResourceContext { + async execute(uri: string, params: Record) { + // Return a plain object -- FrontMCP wraps it in { contents: [{ uri, text: JSON.stringify(...) }] } + return { status: 'healthy', uptime: process.uptime() }; + } +} +``` + +Supported return shapes: + +- **Full `ReadResourceResult`**: `{ contents: [...] }` -- passed through as-is +- **Array of content items**: each item with `text` or `blob` is treated as a content entry +- **Plain string**: wrapped into a single text content block +- **Plain object**: serialized with `JSON.stringify` into a single text content block + +## Resource Templates with @ResourceTemplate + +### Decorator Options + +The `@ResourceTemplate` decorator accepts: + +- `name` (required) -- unique resource template name +- `uriTemplate` (required) -- URI pattern with `{paramName}` placeholders (RFC 6570 style) +- `description` (optional) -- human-readable description +- `mimeType` (optional) -- MIME type of the resource content + +### Class-Based Pattern + +Use `@ResourceTemplate` with `uriTemplate` instead of `uri`. Type the `ResourceContext` generic parameter to get typed `params`. + +```typescript +import { ResourceTemplate, ResourceContext } from '@frontmcp/sdk'; +import { ReadResourceResult } from '@frontmcp/protocol'; + +@ResourceTemplate({ + name: 'user-profile', + uriTemplate: 'users://{userId}/profile', + description: 'User profile by ID', + mimeType: 'application/json', +}) +class UserProfileResource extends ResourceContext<{ userId: string }> { + async execute(uri: string, params: { userId: string }): Promise { + const user = await this.fetchUser(params.userId); + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(user), + }, + ], + }; + } + + private async fetchUser(userId: string) { + return { id: userId, name: 'Alice', email: 'alice@example.com' }; + } +} +``` + +When a client reads `users://u-123/profile`, the framework matches the template and passes `{ userId: 'u-123' }` as `params`. + +### Templates with Multiple Parameters + +```typescript +@ResourceTemplate({ + name: 'repo-file', + uriTemplate: 'repo://{owner}/{repo}/files/{path}', + description: 'File content from a repository', + mimeType: 'text/plain', +}) +class RepoFileResource extends ResourceContext<{ owner: string; repo: string; path: string }> { + async execute(uri: string, params: { owner: string; repo: string; path: string }): Promise { + const content = await this.fetchFileContent(params.owner, params.repo, params.path); + + return { + contents: [ + { + uri, + mimeType: this.metadata.mimeType ?? 'text/plain', + text: content, + }, + ], + }; + } + + private async fetchFileContent(owner: string, repo: string, path: string): Promise { + const response = await this.fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`); + const data = await response.json(); + return Buffer.from(data.content, 'base64').toString('utf-8'); + } +} +``` + +## Function-Style Builders + +For simple cases, use `resource()` and `resourceTemplate()` function builders. + +**Static resource:** + +```typescript +import { resource } from '@frontmcp/sdk'; + +const SystemInfo = resource({ + name: 'system-info', + uri: 'system://info', + mimeType: 'application/json', +})((uri) => ({ + contents: [ + { + uri, + text: JSON.stringify({ + platform: process.platform, + nodeVersion: process.version, + memoryUsage: process.memoryUsage(), + }), + }, + ], +})); +``` + +**Resource template:** + +```typescript +import { resourceTemplate } from '@frontmcp/sdk'; + +const LogFile = resourceTemplate({ + name: 'log-file', + uriTemplate: 'logs://{date}/{level}', + mimeType: 'text/plain', +})((uri, params) => ({ + contents: [ + { + uri, + text: `Logs for ${params.date} at level ${params.level}`, + }, + ], +})); +``` + +Register them the same way as class resources: `resources: [SystemInfo, LogFile]`. + +## Remote and ESM Loading + +Load resources from external modules or remote URLs. + +**ESM loading:** + +```typescript +const ExternalResource = Resource.esm('@my-org/resources@^1.0.0', 'ExternalResource', { + description: 'A resource loaded from an ES module', +}); +``` + +**Remote loading:** + +```typescript +const CloudResource = Resource.remote('https://example.com/resources/data', 'CloudResource', { + description: 'A resource loaded from a remote server', +}); +``` + +Both return values that can be registered in `resources: [ExternalResource, CloudResource]`. + +## Binary Content with Blob + +Return binary data as base64-encoded blobs: + +```typescript +@Resource({ + name: 'app-logo', + uri: 'assets://logo.png', + description: 'Application logo image', + mimeType: 'image/png', +}) +class AppLogoResource extends ResourceContext { + async execute(uri: string, params: Record): Promise { + const { readFileBuffer } = await import('@frontmcp/utils'); + const buffer = await readFileBuffer('/assets/logo.png'); + + return { + contents: [ + { + uri, + mimeType: 'image/png', + blob: buffer.toString('base64'), + }, + ], + }; + } +} +``` + +## Multiple Content Items + +A single resource can return multiple content entries: + +```typescript +@Resource({ + name: 'dashboard-data', + uri: 'dashboard://overview', + description: 'Dashboard overview with metrics and chart data', + mimeType: 'application/json', +}) +class DashboardResource extends ResourceContext { + async execute(uri: string, params: Record): Promise { + const metrics = await this.loadMetrics(); + const chartData = await this.loadChartData(); + + return { + contents: [ + { + uri: `${uri}#metrics`, + mimeType: 'application/json', + text: JSON.stringify(metrics), + }, + { + uri: `${uri}#charts`, + mimeType: 'application/json', + text: JSON.stringify(chartData), + }, + ], + }; + } + + private async loadMetrics() { + return { users: 1500, revenue: 42000 }; + } + private async loadChartData() { + return { labels: ['Jan', 'Feb'], values: [100, 200] }; + } +} +``` + +## Dependency Injection + +Resources have access to the same DI utilities as tools: + +```typescript +import type { Token } from '@frontmcp/di'; + +interface CacheService { + get(key: string): Promise; + set(key: string, value: string, ttlMs: number): Promise; +} +const CACHE: Token = Symbol('cache'); + +@ResourceTemplate({ + name: 'cached-data', + uriTemplate: 'cache://{key}', + description: 'Cached data by key', + mimeType: 'application/json', +}) +class CachedDataResource extends ResourceContext<{ key: string }> { + async execute(uri: string, params: { key: string }): Promise { + const cache = this.get(CACHE); + const value = await cache.get(params.key); + + if (!value) { + this.fail(new Error(`Cache key not found: ${params.key}`)); + } + + return { + contents: [{ uri, mimeType: 'application/json', text: value }], + }; + } +} +``` + +## Registration + +Add resource classes (or function-style resources) to the `resources` array in `@FrontMcp` or `@App`. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'my-app', + resources: [AppConfigResource, UserProfileResource, SystemInfo, LogFile], +}) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + resources: [DashboardResource], // can also register resources directly on the server +}) +class MyServer {} +``` + +## URI Validation Rules + +All resource URIs are validated per RFC 3986 at metadata level: + +- Must have a valid scheme (e.g., `file://`, `https://`, `config://`, `custom://`). +- Scheme-less URIs like `my-resource` will be rejected at registration time. +- Template URIs must also have a valid scheme: `users://{id}` is valid, `{id}/profile` is not. +- URI validation happens at decorator parse time, so errors surface immediately during server startup. + +## Nx Generator + +Scaffold a new resource using the Nx generator: + +```bash +nx generate @frontmcp/nx:resource +``` + +This creates the resource file, spec file, and updates barrel exports. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| URI scheme | `uri: 'config://app/settings'` (valid scheme) | `uri: 'app-settings'` (no scheme) | URIs are validated per RFC 3986; scheme-less URIs are rejected at registration | +| Resource vs template | `@Resource` for fixed URIs, `@ResourceTemplate` for `{param}` URIs | Using `@Resource` with `{param}` placeholders | Framework selects matching strategy based on decorator type | +| Return shape | Return full `ReadResourceResult` or let FrontMCP normalize plain objects | Manually wrapping every return in `{ contents: [...] }` when not needed | FrontMCP auto-wraps strings, objects, and arrays into valid `ReadResourceResult` | +| Template params typing | `ResourceContext<{ userId: string }>` with typed `params` | `ResourceContext` with untyped `params: Record` | Generic parameter enables compile-time checking of URI parameters | +| Binary content | Use `blob` field with base64 encoding for binary data | Returning raw `Buffer` in `text` field | MCP protocol expects base64 in `blob`; `text` is for string content only | + +## Verification Checklist + +### Configuration + +- [ ] Resource class extends `ResourceContext` and implements `execute(uri, params)` +- [ ] `@Resource` has `name` and `uri` with a valid scheme, or `@ResourceTemplate` has `name` and `uriTemplate` +- [ ] Resource is registered in `resources` array of `@App` or `@FrontMcp` +- [ ] `mimeType` is set when the content type is not plain text + +### Runtime + +- [ ] Resource appears in `resources/list` MCP response +- [ ] Reading the resource URI returns the expected `ReadResourceResult` +- [ ] Template parameters are extracted correctly from the URI +- [ ] Binary resources return valid base64 in the `blob` field +- [ ] DI dependencies resolve correctly via `this.get()` + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------- | +| Resource not appearing in `resources/list` | Not registered in `resources` array | Add resource class to `@App` or `@FrontMcp` `resources` array | +| URI validation error at startup | Missing or invalid URI scheme | Ensure URI has a scheme like `config://`, `https://`, or `custom://` | +| Template parameters are empty | Using `@Resource` instead of `@ResourceTemplate` | Switch to `@ResourceTemplate` with `uriTemplate` containing `{param}` placeholders | +| Binary content is garbled | Returning raw buffer in `text` field | Use `blob: buffer.toString('base64')` instead of `text` for binary data | +| `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` | + +## Reference + +- [Resources Documentation](https://docs.agentfront.dev/frontmcp/servers/resources) +- Related skills: `create-tool`, `create-prompt`, `create-provider`, `create-agent` diff --git a/libs/skills/catalog/frontmcp-development/references/create-skill-with-tools.md b/libs/skills/catalog/frontmcp-development/references/create-skill-with-tools.md new file mode 100644 index 00000000..b32935e8 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-skill-with-tools.md @@ -0,0 +1,624 @@ +# Creating a Skill with Tools + +Skills are knowledge and workflow guides that help LLMs accomplish multi-step tasks using available MCP tools. Unlike tools (which execute actions directly) or agents (which run autonomous LLM loops), skills provide structured instructions, tool references, and context that the AI client uses to orchestrate tool calls on its own. + +## When to Use This Skill + +### Must Use + +- Teaching an AI client how to accomplish a complex task by combining multiple tools in a defined sequence +- Building directory-based skills with `SKILL.md`, scripts, references, and assets loaded via `skillDir()` +- Defining tool-orchestration instructions with explicit tool references, parameters, and examples + +### Recommended + +- Creating reusable workflow guides that can be discovered via HTTP (`/llm.txt`, `/skills`) or MCP protocol +- Wrapping existing tools into a higher-level procedure with step-by-step instructions and validation modes +- Providing AI clients with structured playbooks for incident response, deployment, or data-processing flows + +### Skip When + +- You need a single executable action with direct input/output (see `create-tool`) +- You need an autonomous LLM loop that reasons across multiple steps on its own (see `create-agent`) +- You are building a conversational template or system prompt without tool references (see `create-prompt`) + +> **Decision:** Use this skill when you need to guide an AI client through a multi-tool workflow using structured instructions and tool references, without executing anything directly. + +| Aspect | @Skill | @Tool | @Agent | +| ---------- | ------------------------ | -------------------- | -------------------- | +| Execution | None (instructions only) | Direct function call | Autonomous LLM loop | +| Purpose | Workflow guide for AI | Single action | Multi-step reasoning | +| Tool usage | References tools by name | Is a tool | Has inner tools | +| Output | Instructions + tool refs | Computed result | LLM-generated result | + +## Class-Based Pattern + +Create a class extending `SkillContext` and implement the `build(): Promise` method. The `@Skill` decorator requires at minimum a `name` and `description`. + +```typescript +import { Skill, SkillContext } from '@frontmcp/sdk'; + +@Skill({ + name: 'deploy-service', + description: 'Deploy a service through the build, test, and release pipeline', + instructions: `# Deploy Service Workflow + +## Step 1: Build +Use the \`build_project\` tool to compile the service. +Pass the service name and target environment. + +## Step 2: Run Tests +Use the \`run_tests\` tool to execute the test suite. +If tests fail, stop and report the failures. + +## Step 3: Deploy +Use the \`deploy_to_env\` tool to push the build to the target environment. +Verify the deployment using \`health_check\` tool. + +## Step 4: Notify +Use the \`send_notification\` tool to notify the team of the deployment status.`, + tools: [BuildProjectTool, RunTestsTool, DeployToEnvTool, HealthCheckTool, SendNotificationTool], +}) +class DeployServiceSkill extends SkillContext {} +``` + +### Available Context Methods + +`SkillContext` provides: + +- `loadInstructions(): Promise` -- load and return the skill's instructions content +- `build(): Promise` -- build the full skill content (instructions + tool refs + metadata) +- `getToolRefs(): SkillToolRef[]` -- get the list of tool references +- `getToolNames(): string[]` -- get the list of tool names + +## Tool References: Three Ways to Specify Tools + +The `tools` array in `@Skill` metadata supports three ways to reference tools that the skill uses in its instructions. + +### 1. Class Reference + +Pass the tool class directly. The framework resolves the tool name and validates it exists in the registry. + +```typescript +@Skill({ + name: 'data-pipeline', + description: 'Run a data processing pipeline', + instructions: 'Use extract_data, transform_data, and load_data in sequence.', + tools: [ExtractDataTool, TransformDataTool, LoadDataTool], +}) +class DataPipelineSkill extends SkillContext {} +``` + +### 2. String Name + +Reference a tool by its registered name. Useful for tools registered elsewhere or external tools. + +```typescript +@Skill({ + name: 'code-review', + description: 'Review code changes', + instructions: 'Use git_diff to get changes, then use analyze_code to review them.', + tools: ['git_diff', 'analyze_code', 'post_comment'], +}) +class CodeReviewSkill extends SkillContext {} +``` + +### 3. Object with Metadata + +Provide a detailed reference with name, purpose description, and required flag. + +```typescript +@Skill({ + name: 'incident-response', + description: 'Respond to production incidents', + instructions: `# Incident Response + +## Step 1: Gather Information +Use check_service_health to determine which services are affected. +Use query_logs to find error patterns. + +## Step 2: Mitigate +Use rollback_deployment if a recent deploy caused the issue. +Use scale_service if the issue is load-related. + +## Step 3: Communicate +Use send_notification to update the incident channel.`, + tools: [ + { name: 'check_service_health', purpose: 'Check health status of services', required: true }, + { name: 'query_logs', purpose: 'Search application logs for errors', required: true }, + { name: 'rollback_deployment', purpose: 'Rollback to previous deployment', required: false }, + { name: 'scale_service', purpose: 'Scale service replicas up or down', required: false }, + { name: 'send_notification', purpose: 'Send notification to Slack channel', required: true }, + ], +}) +class IncidentResponseSkill extends SkillContext {} +``` + +You can mix all three styles in a single `tools` array: + +```typescript +tools: [ + BuildProjectTool, // class reference + 'run_tests', // string name + { name: 'deploy', purpose: 'Deploy to environment', required: true }, // object +], +``` + +## Tool Validation Modes + +The `toolValidation` field controls what happens when referenced tools are not found in the registry at startup. + +```typescript +@Skill({ + name: 'strict-workflow', + description: 'Workflow that requires all tools to exist', + instructions: '...', + tools: [RequiredToolA, RequiredToolB], + toolValidation: 'strict', // fail if any tool is missing +}) +class StrictWorkflowSkill extends SkillContext {} +``` + +| Mode | Behavior | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `'strict'` | Throws an error if any referenced tool is not registered. Use for production workflows where missing tools would cause failures. | +| `'warn'` | Logs a warning for missing tools but continues. Use during development when tools may not all be available yet. | +| `'ignore'` | Silently ignores missing tools. Use for optional tool references or cross-server skills. | + +## Instruction Sources + +Skills support three ways to provide instructions. + +### Inline String + +```typescript +@Skill({ + name: 'quick-task', + description: 'A simple task', + instructions: 'Step 1: Use tool_a. Step 2: Use tool_b.', +}) +class QuickTaskSkill extends SkillContext {} +``` + +### File Reference + +Load instructions from a Markdown file relative to the skill file: + +```typescript +@Skill({ + name: 'complex-workflow', + description: 'A complex multi-step workflow', + instructions: { file: './skills/complex-workflow.md' }, + tools: [ToolA, ToolB, ToolC], +}) +class ComplexWorkflowSkill extends SkillContext {} +``` + +### URL Reference + +Load instructions from a remote URL: + +```typescript +@Skill({ + name: 'remote-workflow', + description: 'Workflow with remote instructions', + instructions: { url: 'https://docs.example.com/workflows/deploy.md' }, + tools: ['build', 'test', 'deploy'], +}) +class RemoteWorkflowSkill extends SkillContext {} +``` + +## Directory-Based Skills with skillDir() + +Use `skillDir()` to load a skill from a directory structure. The directory is expected to contain a `SKILL.md` file with frontmatter and instructions, plus optional subdirectories for scripts, references, and assets. + +```text +skills/ + deploy-service/ + SKILL.md # Instructions with YAML frontmatter + scripts/ + validate.sh # Helper scripts + smoke-test.sh + references/ + architecture.md # Reference documentation + runbook.md + assets/ + topology.png # Visual assets +``` + +```typescript +import { skillDir } from '@frontmcp/sdk'; + +const DeployServiceSkill = await skillDir('./skills/deploy-service'); +``` + +The `SKILL.md` file uses YAML frontmatter for metadata, followed by the instructions body: + +```markdown +--- +name: deploy-service +description: Deploy a service through the full pipeline +tags: [deploy, ci-cd, production] +tools: + - name: build_project + purpose: Compile the service + required: true + - name: run_tests + purpose: Execute test suite + required: true + - name: deploy_to_env + purpose: Push build to target environment + required: true +parameters: + - name: environment + description: Target deployment environment + type: string + required: true +examples: + - scenario: Deploy to staging + expected-outcome: Service deployed and health check passes +--- + +# Deploy Service + +Follow these steps to deploy the service... +``` + +## Skill Parameters + +Parameters let callers customize skill behavior. They appear in the skill's metadata and can be used in instructions. + +```typescript +@Skill({ + name: 'setup-project', + description: 'Set up a new project from a template', + instructions: 'Use create_project tool with the specified template and language.', + tools: ['create_project', 'install_dependencies', 'init_git'], + parameters: [ + { name: 'template', description: 'Project template to use', type: 'string', required: true }, + { name: 'language', description: 'Programming language', type: 'string', default: 'typescript' }, + { name: 'include-ci', description: 'Include CI configuration', type: 'boolean', default: true }, + ], +}) +class SetupProjectSkill extends SkillContext {} +``` + +## Skill Examples + +Examples show the AI how the skill should be used and what outcomes to expect: + +```typescript +@Skill({ + name: 'database-migration', + description: 'Run database migrations safely', + instructions: '...', + tools: ['generate_migration', 'run_migration', 'rollback_migration', 'backup_database'], + examples: [ + { + scenario: 'Add a new column to the users table', + expectedOutcome: 'Migration generated, backup created, migration applied, verified', + }, + { + scenario: 'Rollback a failed migration', + expectedOutcome: 'Failed migration identified, rolled back, database restored to previous state', + }, + ], +}) +class DatabaseMigrationSkill extends SkillContext {} +``` + +## Skill Visibility + +Control where the skill is discoverable using the `visibility` field: + +```typescript +@Skill({ + name: 'internal-deploy', + description: 'Internal deployment workflow', + instructions: '...', + visibility: 'mcp', // Only visible via MCP protocol +}) +class InternalDeploySkill extends SkillContext {} +``` + +| Value | Description | +| -------- | ------------------------------------------------------- | +| `'mcp'` | Visible only via MCP protocol (tool listing) | +| `'http'` | Visible only via HTTP endpoints (`/llm.txt`, `/skills`) | +| `'both'` | Visible via both MCP and HTTP (default) | + +## Hiding Skills from Discovery + +Use `hideFromDiscovery: true` to register a skill that is not listed in discovery endpoints but can still be invoked directly: + +```typescript +@Skill({ + name: 'admin-maintenance', + description: 'Internal maintenance procedures', + instructions: '...', + hideFromDiscovery: true, +}) +class AdminMaintenanceSkill extends SkillContext {} +``` + +## Function-Style Builder + +For skills that do not need a class, use the `skill()` function builder: + +```typescript +import { skill } from '@frontmcp/sdk'; + +const QuickDeploySkill = skill({ + name: 'quick-deploy', + description: 'Quick deployment to staging', + instructions: `# Quick Deploy +1. Use build_project to compile. +2. Use deploy_to_env with environment=staging. +3. Use health_check to verify.`, + tools: ['build_project', 'deploy_to_env', 'health_check'], +}); +``` + +Register it the same way as a class skill: `skills: [QuickDeploySkill]`. + +## Remote and ESM Loading + +Load skills from external modules or remote URLs without importing them directly. + +**ESM loading** -- load a skill from an ES module: + +```typescript +const ExternalSkill = Skill.esm('@my-org/skills@^1.0.0', 'ExternalSkill', { + description: 'A skill loaded from an ES module', +}); +``` + +**Remote loading** -- load a skill from a remote URL: + +```typescript +const CloudSkill = Skill.remote('https://example.com/skills/cloud-skill', 'CloudSkill', { + description: 'A skill loaded from a remote server', +}); +``` + +Both return values that can be registered in `skills: [ExternalSkill, CloudSkill]`. + +## Registration + +Add skill classes (or function-style skills) to the `skills` array in `@FrontMcp` or `@App`. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'devops-app', + skills: [DeployServiceSkill, IncidentResponseSkill], + tools: [BuildProjectTool, RunTestsTool, DeployToEnvTool], +}) +class DevOpsApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [DevOpsApp], + skills: [QuickDeploySkill], // can also register skills directly on the server +}) +class MyServer {} +``` + +## Nx Generators + +Scaffold a new skill using the Nx generators: + +```bash +# Create a skill class file +nx generate @frontmcp/nx:skill + +# Create a directory-based skill with SKILL.md, scripts/, references/, assets/ +nx generate @frontmcp/nx:skill-dir +``` + +The class generator creates the skill file, spec file, and updates barrel exports. The directory generator creates the full directory structure ready for `skillDir()`. + +## HTTP Endpoints for Skill Discovery + +When skills have `visibility` set to `'http'` or `'both'`, they are discoverable via HTTP endpoints: + +### /llm.txt + +Returns a plain-text document listing all HTTP-visible skills with their descriptions and instructions. This endpoint follows the `llm.txt` convention for AI-readable site documentation. + +``` +GET /llm.txt + +# Skills + +## deploy-service +Deploy a service through the build, test, and release pipeline +Tools: build_project, run_tests, deploy_to_env, health_check, send_notification +... +``` + +### /skills + +Returns a JSON array of all HTTP-visible skills with full metadata: + +``` +GET /skills + +[ + { + "name": "deploy-service", + "description": "Deploy a service through the build, test, and release pipeline", + "instructions": "...", + "tools": ["build_project", "run_tests", "deploy_to_env"], + "parameters": [...], + "examples": [...], + "tags": ["deploy", "ci-cd"], + "visibility": "both" + } +] +``` + +## Complete Example: Multi-Tool Orchestration Skill + +```typescript +import { Skill, SkillContext, Tool, ToolContext, FrontMcp, App } from '@frontmcp/sdk'; +import { z } from 'zod'; + +// Define the tools that the skill references + +@Tool({ + name: 'analyze_codebase', + description: 'Analyze a codebase for patterns and issues', + inputSchema: { + path: z.string().describe('Path to the codebase'), + checks: z.array(z.string()).describe('Checks to run'), + }, +}) +class AnalyzeCodebaseTool extends ToolContext { + async execute(input: { path: string; checks: string[] }) { + return { issues: [], score: 95 }; + } +} + +@Tool({ + name: 'generate_report', + description: 'Generate a Markdown report from analysis results', + inputSchema: { + title: z.string(), + sections: z.array(z.object({ heading: z.string(), content: z.string() })), + }, +}) +class GenerateReportTool extends ToolContext { + async execute(input: { title: string; sections: { heading: string; content: string }[] }) { + return `# ${input.title}\n${input.sections.map((s) => `## ${s.heading}\n${s.content}`).join('\n')}`; + } +} + +@Tool({ + name: 'create_issue', + description: 'Create a GitHub issue for a found problem', + inputSchema: { + title: z.string(), + body: z.string(), + labels: z.array(z.string()).optional(), + }, +}) +class CreateIssueTool extends ToolContext { + async execute(input: { title: string; body: string; labels?: string[] }) { + return { issueNumber: 42, url: 'https://github.com/org/repo/issues/42' }; + } +} + +// Define the skill that orchestrates these tools + +@Skill({ + name: 'codebase-audit', + description: 'Perform a comprehensive codebase audit with reporting and issue creation', + instructions: `# Codebase Audit Workflow + +## Step 1: Analyze +Use the \`analyze_codebase\` tool to scan the codebase. +Run these checks: ["security", "performance", "maintainability", "testing"]. + +## Step 2: Review Results +Examine the analysis output. Group issues by severity (critical, warning, info). + +## Step 3: Generate Report +Use \`generate_report\` to create a Markdown report with sections for each check category. +Include the overall score and a summary of findings. + +## Step 4: Create Issues +For each critical issue found, use \`create_issue\` to file a GitHub issue. +Label critical issues with "priority:high" and "audit". +Label warnings with "priority:medium" and "audit". + +## Step 5: Summary +Provide a final summary with: +- Total issues found by severity +- Overall codebase score +- Links to created GitHub issues`, + tools: [ + AnalyzeCodebaseTool, + GenerateReportTool, + { name: 'create_issue', purpose: 'File GitHub issues for critical findings', required: false }, + ], + toolValidation: 'strict', + parameters: [ + { name: 'path', description: 'Path to the codebase to audit', type: 'string', required: true }, + { name: 'create-issues', description: 'Whether to create GitHub issues', type: 'boolean', default: true }, + ], + examples: [ + { + scenario: 'Audit a Node.js API project', + expectedOutcome: 'Analysis complete, report generated, critical issues filed on GitHub', + }, + ], + tags: ['audit', 'code-quality', 'github'], + visibility: 'both', +}) +class CodebaseAuditSkill extends SkillContext {} + +// Register everything + +@App({ + name: 'audit-app', + skills: [CodebaseAuditSkill], + tools: [AnalyzeCodebaseTool, GenerateReportTool, CreateIssueTool], +}) +class AuditApp {} + +@FrontMcp({ + info: { name: 'audit-server', version: '1.0.0' }, + apps: [AuditApp], +}) +class AuditServer {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Tool references | `tools: [BuildTool, 'run_tests', { name: 'deploy', purpose: '...', required: true }]` | `tools: [{ class: BuildTool }]` (object with `class` key) | The `tools` array accepts class refs, strings, or `{ name, purpose, required }` objects only | +| Tool validation | `toolValidation: 'strict'` for production skills | Omitting `toolValidation` for critical workflows | Default is `'warn'`; production skills should fail fast on missing tools with `'strict'` | +| Instruction source | `instructions: { file: './skills/deploy.md' }` for long content | Inlining hundreds of lines in the decorator string | File-based instructions keep decorator metadata readable and instructions maintainable | +| Skill visibility | `visibility: 'both'` (default) for public skills | Setting `visibility: 'mcp'` when HTTP discovery is also needed | Skills with `'mcp'` visibility are hidden from `/llm.txt` and `/skills` HTTP endpoints | +| Parameter types | `parameters: [{ name: 'env', type: 'string', required: true }]` | `parameters: { env: 'string' }` (plain object shape) | Parameters must be an array of `{ name, description, type, required?, default? }` objects | + +## Verification Checklist + +### Configuration + +- [ ] `@Skill` decorator has `name` and `description` +- [ ] `instructions` are provided via inline string, `{ file }`, or `{ url }` +- [ ] All tool references in `tools` array resolve to registered tools (when `toolValidation: 'strict'`) +- [ ] Skill is registered in `skills` array of `@App` or `@FrontMcp` + +### Runtime + +- [ ] Skill appears in MCP skill listing (`skills/list`) when `visibility` includes `'mcp'` +- [ ] Skill appears at `/llm.txt` and `/skills` HTTP endpoints when `visibility` includes `'http'` +- [ ] `build()` returns complete `SkillContent` with instructions and tool references +- [ ] `getToolRefs()` returns the correct list of resolved tool references +- [ ] Hidden skills (`hideFromDiscovery: true`) are invocable but not listed in discovery + +### Directory-Based Skills + +- [ ] `SKILL.md` file exists at the root of the skill directory with valid YAML frontmatter +- [ ] `skillDir()` correctly loads instructions, scripts, references, and assets +- [ ] Frontmatter `tools` entries match registered tool names + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Skill not appearing in `/llm.txt` | `visibility` is set to `'mcp'` | Change to `'both'` or `'http'` to include HTTP discovery | +| `toolValidation: 'strict'` throws at startup | A referenced tool is not registered in the scope | Register all referenced tools in the `tools` array of `@App` or `@FrontMcp` | +| `skillDir()` fails to load | `SKILL.md` file missing or frontmatter is invalid YAML | Ensure the directory contains a `SKILL.md` with valid `---` delimited YAML frontmatter | +| Instructions are empty at runtime | `{ file: './path.md' }` path is relative to wrong directory | Use a path relative to the skill file's location, not the project root | +| Parameters not visible to AI client | `parameters` defined as a plain object instead of an array | Use array format: `[{ name, description, type, required }]` | + +## Reference + +- [Skills Documentation](https://docs.agentfront.dev/frontmcp/servers/skills) +- Related skills: `create-skill`, `create-tool`, `create-agent`, `create-prompt` diff --git a/libs/skills/catalog/frontmcp-development/references/create-skill.md b/libs/skills/catalog/frontmcp-development/references/create-skill.md new file mode 100644 index 00000000..a0110e89 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-skill.md @@ -0,0 +1,577 @@ +# Creating Instruction-Only Skills + +Skills are knowledge and workflow packages that teach AI clients how to accomplish tasks. Unlike tools (which execute actions) or agents (which run autonomous LLM loops), a skill provides structured instructions that the AI follows on its own. An instruction-only skill contains no tool references -- it is purely a guide. + +## When to Use This Skill + +### Must Use + +- You need to package knowledge, conventions, or workflow steps as a reusable skill that AI clients can follow +- You are creating a SKILL.md catalog entry or a class/function-based skill with no tool dependencies +- You want to enforce coding standards, onboarding steps, or review criteria through structured AI guidance + +### Recommended + +- You are building a deployment runbook, architecture decision record, or quality gate checklist +- You want to share workflow templates across teams via MCP or HTTP discovery endpoints +- You need parameterized instructions that callers can customize per invocation + +### Skip When + +- The skill must invoke MCP tools during execution -- use `create-skill-with-tools` instead +- You need an autonomous agent loop rather than static instructions -- use an agent pattern instead +- The content is a one-off prompt with no reuse value -- a plain prompt template is simpler + +> **Decision:** Pick this skill when you need a reusable, instruction-only knowledge package that guides AI through a workflow without requiring tool calls. + +## Class-Based Pattern + +Create a class extending `SkillContext` and decorate it with `@Skill`. The decorator requires `name`, `description`, and `instructions`. + +### SkillMetadata Fields + +| Field | Type | Required | Description | +| ------------------- | ----------------------------------------------- | -------- | ----------------------------------------------------------- | +| `name` | `string` | Yes | Unique skill name in kebab-case | +| `description` | `string` | Yes | Short description of what the skill teaches | +| `instructions` | `string \| { file: string } \| { url: string }` | Yes | The skill content -- see instruction sources below | +| `parameters` | `SkillParameter[]` | No | Customization parameters for the skill | +| `examples` | `SkillExample[]` | No | Usage scenarios and expected outcomes | +| `tags` | `string[]` | No | Categorization tags for discovery | +| `visibility` | `'mcp' \| 'http' \| 'both'` | No | Where the skill is discoverable (default: `'both'`) | +| `hideFromDiscovery` | `boolean` | No | Register but hide from listing endpoints (default: `false`) | + +### Basic Example + +```typescript +import { Skill, SkillContext } from '@frontmcp/sdk'; + +@Skill({ + name: 'typescript-conventions', + description: 'TypeScript coding conventions and patterns for the project', + instructions: `# TypeScript Conventions + +## Naming +- Use PascalCase for classes and interfaces +- Use camelCase for variables, functions, and methods +- Use UPPER_SNAKE_CASE for constants +- Use kebab-case for file names + +## Types +- Always use explicit return types on public methods +- Prefer \`unknown\` over \`any\` for generic defaults +- Use strict mode (\`strict: true\` in tsconfig) +- Define shared types in a common directory + +## Error Handling +- Use specific error classes, not raw Error +- Never use non-null assertions (\`!\`) -- throw proper errors +- Use \`this.fail(err)\` in execution contexts + +## Imports +- Use barrel exports (index.ts) for public APIs +- No circular dependencies +- Group imports: external, internal, relative`, +}) +class TypeScriptConventionsSkill extends SkillContext {} +``` + +### Available Context Methods + +`SkillContext` provides: + +- `loadInstructions(): Promise` -- load and return the resolved instruction content (resolves file or URL references) +- `build(): Promise` -- build the full skill content object (instructions + metadata) + +## Instruction Sources + +Skills support three ways to provide instructions. All three are set via the `instructions` field in `@Skill` metadata. + +### Inline String + +Provide instructions directly as a string. Best for short, self-contained guides. + +```typescript +@Skill({ + name: 'git-commit-guide', + description: 'Guidelines for writing commit messages', + instructions: `# Commit Message Format + +Use conventional commits: type(scope): description + +Types: feat, fix, refactor, test, docs, chore +Scope: the module or area affected +Description: imperative mood, lowercase, no period + +Example: feat(auth): add OAuth2 token refresh`, +}) +class GitCommitGuideSkill extends SkillContext {} +``` + +### File Reference + +Load instructions from a Markdown file. The path is relative to the skill file location. + +```typescript +@Skill({ + name: 'architecture-guide', + description: 'System architecture overview and patterns', + instructions: { file: './docs/architecture.md' }, +}) +class ArchitectureGuideSkill extends SkillContext {} +``` + +### URL Reference + +Load instructions from a remote URL. Fetched at build time when the skill is loaded. + +```typescript +@Skill({ + name: 'api-standards', + description: 'REST API design standards', + instructions: { url: 'https://docs.example.com/standards/api-design.md' }, +}) +class ApiStandardsSkill extends SkillContext {} +``` + +## SkillContext: loadInstructions() and build() + +The `SkillContext` class resolves instructions regardless of the source type. When the framework serves a skill, it calls `build()` which internally calls `loadInstructions()`. + +```typescript +@Skill({ + name: 'onboarding', + description: 'Developer onboarding checklist', + instructions: { file: './onboarding-checklist.md' }, +}) +class OnboardingSkill extends SkillContext { + // You can override build() to add custom logic + async build(): Promise { + const content = await super.build(); + // Add dynamic content if needed + return content; + } +} +``` + +The `build()` method returns a `SkillContent` object: + +```typescript +interface SkillContent { + id: string; // unique identifier (derived from name if not provided) + name: string; + description: string; + instructions: string; // resolved instruction text + tools: Array<{ name: string; purpose?: string; required?: boolean }>; + parameters?: SkillParameter[]; + examples?: Array<{ scenario: string; parameters?: Record; expectedOutcome?: string }>; + license?: string; + compatibility?: string; + specMetadata?: Record; + allowedTools?: string; // space-delimited pre-approved tools + resources?: SkillResources; // bundled scripts/, references/, assets/ +} +``` + +## Function Builder + +For skills that do not need a class, use the `skill()` function builder. Instruction-only skills have no execute function -- they are purely declarative. + +```typescript +import { skill } from '@frontmcp/sdk'; + +const CodeReviewChecklist = skill({ + name: 'code-review-checklist', + description: 'Checklist for reviewing pull requests', + instructions: `# Code Review Checklist + +## Correctness +- Does the code do what it claims? +- Are edge cases handled? +- Are error paths covered? + +## Style +- Does it follow project conventions? +- Are names descriptive and consistent? +- Is the code self-documenting? + +## Testing +- Are there tests for new functionality? +- Do tests cover edge cases? +- Is coverage above 95%? + +## Security +- No secrets in code or config? +- Input validation present? +- Proper error handling without leaking internals?`, + visibility: 'both', +}); +``` + +Register it the same way as a class skill: `skills: [CodeReviewChecklist]`. + +## Directory-Based Skills with skillDir() + +Use `skillDir()` to load a skill from a directory containing a `SKILL.md` file with YAML frontmatter, plus optional subdirectories for scripts, references, and assets. + +### Directory Structure + +```text +skills/ + coding-standards/ + SKILL.md # Instructions with YAML frontmatter + scripts/ + lint-check.sh # Helper scripts referenced in instructions + references/ + patterns.md # Reference documentation appended to context + assets/ + diagram.png # Visual assets +``` + +### Loading a Skill Directory + +```typescript +import { skillDir } from '@frontmcp/sdk'; + +const CodingStandards = await skillDir('./skills/coding-standards'); +``` + +The `SKILL.md` file uses YAML frontmatter for metadata, followed by the instructions body: + +```markdown +--- +name: coding-standards +description: Project coding standards and patterns +tags: [standards, conventions, quality] +parameters: + - name: language + description: Target programming language + type: string + default: typescript +examples: + - scenario: Apply coding standards to a new module + expected-outcome: Code follows all project conventions +--- + +# Coding Standards + +Follow these standards when writing code for this project... +``` + +Files in `scripts/`, `references/`, and `assets/` are automatically bundled with the skill and available in the skill content. + +## Parameters + +Parameters let callers customize skill behavior. They appear in the skill metadata and can influence how the AI applies the instructions. + +```typescript +@Skill({ + name: 'api-design-guide', + description: 'REST API design guidelines', + instructions: `# API Design Guide + +Design APIs following these conventions. +Adapt the versioning strategy based on the api-style parameter. +Use the auth-required parameter to determine if authentication sections apply.`, + parameters: [ + { name: 'api-style', description: 'API style to follow', type: 'string', default: 'rest' }, + { name: 'auth-required', description: 'Whether to include auth guidelines', type: 'boolean', default: true }, + { name: 'version-strategy', description: 'API versioning approach', type: 'string', default: 'url-path' }, + ], +}) +class ApiDesignGuideSkill extends SkillContext {} +``` + +## Examples for AI Guidance + +Examples show the AI how the skill should be applied and what outcomes to expect: + +```typescript +@Skill({ + name: 'error-handling-guide', + description: 'Error handling patterns and best practices', + instructions: '...', + examples: [ + { + scenario: 'Adding error handling to a new API endpoint', + expectedOutcome: + 'Endpoint uses specific error classes with MCP error codes, validates input, and returns structured error responses', + }, + { + scenario: 'Refactoring try-catch blocks in existing code', + expectedOutcome: 'Generic catches replaced with specific error types, proper error propagation chain established', + }, + ], +}) +class ErrorHandlingGuideSkill extends SkillContext {} +``` + +## Visibility + +Control where the skill is discoverable using the `visibility` field. + +| Value | Description | +| -------- | ------------------------------------------------------- | +| `'mcp'` | Visible only via MCP protocol (tool listing) | +| `'http'` | Visible only via HTTP endpoints (`/llm.txt`, `/skills`) | +| `'both'` | Visible via both MCP and HTTP (default) | + +```typescript +@Skill({ + name: 'internal-runbook', + description: 'Internal operations runbook', + instructions: '...', + visibility: 'mcp', // Only visible to MCP clients, not HTTP discovery +}) +class InternalRunbookSkill extends SkillContext {} +``` + +### Hiding from Discovery + +Use `hideFromDiscovery: true` to register a skill that exists but is not listed in any discovery endpoint. It can still be invoked directly by name. + +```typescript +@Skill({ + name: 'admin-procedures', + description: 'Administrative procedures for internal use', + instructions: '...', + hideFromDiscovery: true, +}) +class AdminProceduresSkill extends SkillContext {} +``` + +## Registration + +Add skill classes (or function-style skills) to the `skills` array in `@FrontMcp` or `@App`. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'standards-app', + skills: [TypeScriptConventionsSkill, CodeReviewChecklist, CodingStandards], +}) +class StandardsApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [StandardsApp], + skills: [ApiDesignGuideSkill], // can also register skills directly on the server +}) +class MyServer {} +``` + +## HTTP Discovery + +When skills have `visibility` set to `'http'` or `'both'`, they are discoverable via HTTP endpoints. + +### /llm.txt + +Returns a plain-text document listing all HTTP-visible skills with their descriptions and instructions. + +``` +GET /llm.txt + +# Skills + +## typescript-conventions +TypeScript coding conventions and patterns for the project +... +``` + +### /skills + +Returns a JSON array of all HTTP-visible skills with full metadata. + +``` +GET /skills + +[ + { + "name": "typescript-conventions", + "description": "TypeScript coding conventions and patterns for the project", + "instructions": "...", + "parameters": [], + "tags": [], + "visibility": "both" + } +] +``` + +## Remote and ESM Loading + +Load skills from external modules or remote URLs without importing them directly. + +**ESM loading** -- load a skill from an ES module: + +```typescript +const ExternalGuide = Skill.esm('@my-org/skills@^1.0.0', 'ExternalGuide', { + description: 'A skill loaded from an ES module', +}); +``` + +**Remote loading** -- load a skill from a remote URL: + +```typescript +const CloudGuide = Skill.remote('https://example.com/skills/style-guide', 'CloudGuide', { + description: 'A skill loaded from a remote server', +}); +``` + +Both return values that can be registered in `skills: [ExternalGuide, CloudGuide]`. + +## Nx Generators + +Scaffold a new skill using the Nx generators: + +```bash +# Create a skill class file +nx generate @frontmcp/nx:skill + +# Create a directory-based skill with SKILL.md, scripts/, references/, assets/ +nx generate @frontmcp/nx:skill-dir +``` + +The class generator creates the skill file, spec file, and updates barrel exports. The directory generator creates the full directory structure ready for `skillDir()`. + +## Complete Example: Project Onboarding Skill + +```typescript +import { Skill, SkillContext, FrontMcp, App, skill, skillDir } from '@frontmcp/sdk'; + +// Class-based instruction-only skill +@Skill({ + name: 'project-onboarding', + description: 'Step-by-step guide for onboarding new developers to the project', + instructions: `# Project Onboarding + +## Step 1: Environment Setup +1. Clone the repository +2. Install Node.js 22+ and Yarn +3. Run \`yarn install\` to install dependencies +4. Copy \`.env.example\` to \`.env\` and fill in values + +## Step 2: Understand the Architecture +- This is an Nx monorepo with libraries in \`/libs/*\` +- Each library is independently publishable under \`@frontmcp/*\` +- The SDK is the core package; other packages build on it + +## Step 3: Run Tests +- Run \`nx run-many -t test\` to verify everything works +- Coverage must be 95%+ across all metrics +- All test files use \`.spec.ts\` extension + +## Step 4: Development Workflow +- Create a feature branch from \`main\` +- Follow conventional commit format +- Run \`node scripts/fix-unused-imports.mjs\` before committing +- Ensure all tests pass and no TypeScript warnings exist + +## Step 5: Code Standards +- Use strict TypeScript with no \`any\` types +- Use \`unknown\` for generic defaults +- Use specific MCP error classes +- Follow the patterns in CLAUDE.md`, + parameters: [ + { name: 'team', description: 'Team the developer is joining', type: 'string', required: false }, + { + name: 'focus-area', + description: 'Primary area of focus (sdk, cli, adapters, plugins)', + type: 'string', + default: 'sdk', + }, + ], + examples: [ + { + scenario: 'Onboard a new developer to the SDK team', + expectedOutcome: 'Developer has environment set up, understands architecture, and can run tests', + }, + ], + tags: ['onboarding', 'setup', 'guide'], + visibility: 'both', +}) +class ProjectOnboardingSkill extends SkillContext {} + +// Function-style instruction-only skill +const SecurityChecklist = skill({ + name: 'security-checklist', + description: 'Security review checklist for code changes', + instructions: `# Security Checklist + +- No secrets or credentials in source code +- Use @frontmcp/utils for all crypto operations +- Validate all external input with Zod schemas +- Use specific error classes that do not leak internals +- Check for SQL injection in any raw queries +- Verify CORS configuration for HTTP endpoints +- Ensure authentication is enforced on protected routes`, + visibility: 'mcp', +}); + +// Directory-based instruction-only skill +const ArchitectureGuide = await skillDir('./skills/architecture-guide'); + +@App({ + name: 'onboarding-app', + skills: [ProjectOnboardingSkill, SecurityChecklist, ArchitectureGuide], +}) +class OnboardingApp {} + +@FrontMcp({ + info: { name: 'dev-server', version: '1.0.0' }, + apps: [OnboardingApp], +}) +class DevServer {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Instruction source for short guides | `instructions: 'Use PascalCase for classes...'` | Loading a one-paragraph guide from a separate file | Inline strings keep short skills self-contained and easier to review | +| Instruction source for long content | `instructions: { file: './docs/guide.md' }` | Pasting 200+ lines as a template literal | File references keep the class readable and the content editable in Markdown tooling | +| Skill naming | `name: 'api-design-guide'` (kebab-case) | `name: 'ApiDesignGuide'` or `name: 'api design guide'` | The `name` field must be kebab-case to match registry lookup and URL conventions | +| Visibility for internal runbooks | `visibility: 'mcp'` | `visibility: 'both'` for sensitive content | Internal procedures should not be exposed on public HTTP endpoints like `/llm.txt` | +| Function builder for simple skills | `const s = skill({ name, description, instructions })` | Creating a class with an empty body just to use `@Skill` | The function builder avoids boilerplate when no custom `build()` override is needed | + +## Verification Checklist + +### Structure + +- [ ] Skill has a unique kebab-case `name` +- [ ] `description` is a single sentence explaining what the skill teaches +- [ ] `instructions` field is set (inline string, file reference, or URL reference) +- [ ] No tool references appear in the instructions (instruction-only skill) + +### Metadata + +- [ ] `tags` array includes relevant categorization keywords +- [ ] `visibility` is set appropriately (`'mcp'`, `'http'`, or `'both'`) +- [ ] `parameters` have `name`, `description`, and `type` defined if present +- [ ] `examples` include `scenario` and `expectedOutcome` if present + +### Registration + +- [ ] Skill class or function is added to the `skills` array in `@App` or `@FrontMcp` +- [ ] Barrel export (`index.ts`) is updated if the skill is part of a publishable library +- [ ] Test file (`*.spec.ts`) exists and covers metadata and build output + +### Discovery + +- [ ] Skill appears in `GET /skills` or MCP tool listing based on visibility setting +- [ ] `hideFromDiscovery` is only set to `true` when the skill must be invoked by name only + +## Troubleshooting + +| Problem | Cause | Fix | +| ------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Skill does not appear in `/llm.txt` or `/skills` | `visibility` is set to `'mcp'` or `hideFromDiscovery` is `true` | Set `visibility: 'both'` and `hideFromDiscovery: false` | +| `loadInstructions()` returns empty string | File reference path is wrong or the file is empty | Verify the path is relative to the skill file location and the target file has content | +| `build()` throws "instructions required" | The `instructions` field is missing or `undefined` in `@Skill` metadata | Provide an inline string, `{ file: '...' }`, or `{ url: '...' }` | +| Skill parameters are ignored by the AI | Parameters are declared but not referenced in the instruction text | Mention each parameter by name in the instructions so the AI knows how to apply them | +| Directory-based skill missing bundled files | Subdirectories are not named `scripts/`, `references/`, or `assets/` | Use the exact conventional directory names; other names are not auto-bundled | + +## Reference + +- **Docs:** +- **Related skills:** `create-skill-with-tools` (skills that reference MCP tools), `setup-project` (project scaffolding workflows) diff --git a/libs/skills/catalog/frontmcp-development/references/create-tool-annotations.md b/libs/skills/catalog/frontmcp-development/references/create-tool-annotations.md new file mode 100644 index 00000000..8d45e4c4 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-tool-annotations.md @@ -0,0 +1,34 @@ +# Tool Annotations Reference + +Annotations provide hints to MCP clients about tool behavior: + +```typescript +@Tool({ + name: 'my_tool', + inputSchema: { ... }, + annotations: { + title: 'My Tool', // Human-readable display name + readOnlyHint: true, // Tool only reads data, no side effects + destructiveHint: false, // Tool does NOT destroy/delete data + idempotentHint: true, // Safe to call multiple times with same input + openWorldHint: false, // Tool does NOT interact with external world + }, +}) +``` + +## Fields + +| Field | Type | Default | Description | +| ----------------- | --------- | ------- | ---------------------------------- | +| `title` | `string` | — | Human-friendly display name | +| `readOnlyHint` | `boolean` | `false` | Tool only reads, no mutations | +| `destructiveHint` | `boolean` | `true` | Tool may delete/overwrite data | +| `idempotentHint` | `boolean` | `false` | Repeated calls produce same result | +| `openWorldHint` | `boolean` | `true` | Tool may access external services | + +## Usage Guidance + +- Set `readOnlyHint: true` for query/lookup tools +- Set `destructiveHint: true` for delete/overwrite operations (triggers client warnings) +- Set `idempotentHint: true` for safe-to-retry tools +- Set `openWorldHint: false` for tools that only access local data diff --git a/libs/skills/catalog/frontmcp-development/references/create-tool-output-schema-types.md b/libs/skills/catalog/frontmcp-development/references/create-tool-output-schema-types.md new file mode 100644 index 00000000..87d0d64b --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-tool-output-schema-types.md @@ -0,0 +1,56 @@ +# Output Schema Types Reference + +All supported `outputSchema` types for `@Tool`: + +## Zod Raw Shapes (Recommended) + +```typescript +outputSchema: { + name: z.string(), + count: z.number(), + items: z.array(z.string()), +} +``` + +Produces structured JSON output. **Best practice for CodeCall compatibility and data leak prevention.** + +## Zod Schemas + +```typescript +outputSchema: z.object({ result: z.number() }) +outputSchema: z.array(z.string()) +outputSchema: z.union([z.string(), z.number()]) +outputSchema: z.discriminatedUnion('type', [...]) +``` + +## Primitive Literals + +```typescript +outputSchema: 'string'; // Returns plain text +outputSchema: 'number'; // Returns a number +outputSchema: 'boolean'; // Returns true/false +outputSchema: 'date'; // Returns an ISO date string +``` + +## Media Types + +```typescript +outputSchema: 'image'; // Returns base64 image data +outputSchema: 'audio'; // Returns base64 audio data +outputSchema: 'resource'; // Returns a resource content +outputSchema: 'resource_link'; // Returns a resource URI link +``` + +## Multi-Content Arrays + +```typescript +outputSchema: ['string', 'image']; // Returns text + image content +``` + +## No OutputSchema (Not Recommended) + +When `outputSchema` is omitted, the tool returns unvalidated content. This: + +- Risks leaking internal fields to the client +- Prevents CodeCall from inferring return types +- Loses compile-time type checking on `Out` generic diff --git a/libs/skills/catalog/frontmcp-development/references/create-tool.md b/libs/skills/catalog/frontmcp-development/references/create-tool.md new file mode 100644 index 00000000..8a6d3357 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-tool.md @@ -0,0 +1,454 @@ +# Creating an MCP Tool + +Tools are the primary way to expose executable actions to AI clients in the MCP protocol. In FrontMCP, tools are TypeScript classes that extend `ToolContext`, decorated with `@Tool`, and registered on a `@FrontMcp` server or inside an `@App`. + +## When to Use This Skill + +### Must Use + +- Building a new executable action that AI clients can invoke via MCP +- Defining typed input schemas with Zod validation for tool parameters +- Adding output schema validation to prevent data leaks from tool responses + +### Recommended + +- Adding rate limiting, concurrency control, or timeouts to existing tools +- Integrating dependency injection into tool execution +- Converting raw function handlers into class-based `ToolContext` patterns + +### Skip When + +- Exposing read-only data that does not require execution logic (see `create-resource`) +- Building conversational templates or system prompts (see `create-prompt`) +- Orchestrating multi-tool workflows with conditional logic (see `create-agent`) + +> **Decision:** Use this skill when you need an AI-callable action that accepts validated input, performs work, and returns structured output. + +## Class-Based Pattern + +Create a class extending `ToolContext` and implement the `execute(input: In): Promise` method. The `@Tool` decorator requires at minimum a `name` and an `inputSchema`. + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'greet_user', + description: 'Greet a user by name', + inputSchema: { + name: z.string().describe('The name of the user to greet'), + }, +}) +class GreetUserTool extends ToolContext { + async execute(input: { name: string }) { + return `Hello, ${input.name}!`; + } +} +``` + +### Available Context Methods and Properties + +`ToolContext` extends `ExecutionContextBase`, which provides: + +**Methods:** + +- `execute(input: In): Promise` -- the main method you implement +- `this.get(token)` -- resolve a dependency from DI (throws if not found) +- `this.tryGet(token)` -- resolve a dependency from DI (returns `undefined` if not found) +- `this.fail(err)` -- abort execution, triggers error flow (never returns) +- `this.mark(stage)` -- set the active execution stage for debugging/tracking +- `this.fetch(input, init?)` -- HTTP fetch with context propagation +- `this.notify(message, level?)` -- send a log-level notification to the client +- `this.respondProgress(value, total?)` -- send a progress notification to the client + +**Properties:** + +- `this.input` -- the validated input object +- `this.output` -- the output (available after execute) +- `this.metadata` -- tool metadata from the decorator +- `this.scope` -- the current scope instance +- `this.context` -- the execution context + +## Input Schema: Zod Raw Shapes + +The `inputSchema` accepts a **Zod raw shape** -- a plain object mapping field names to Zod types. Do NOT wrap it in `z.object()`. The framework wraps it internally. + +```typescript +@Tool({ + name: 'search_documents', + description: 'Search documents by query and optional filters', + inputSchema: { + // This is a raw shape, NOT z.object({...}) + query: z.string().min(1).describe('Search query'), + limit: z.number().int().min(1).max(100).default(10).describe('Max results'), + category: z.enum(['blog', 'docs', 'api']).optional().describe('Filter by category'), + }, +}) +class SearchDocumentsTool extends ToolContext { + async execute(input: { query: string; limit: number; category?: 'blog' | 'docs' | 'api' }) { + // input is already validated by Zod before execute() is called + return { results: [], total: 0 }; + } +} +``` + +The `execute()` parameter type must match the inferred output of `z.object(inputSchema)`. Validated input is also available via `this.input`. + +## Output Schema (Recommended Best Practice) + +**Always define `outputSchema` for every tool.** This is a best practice for three critical reasons: + +1. **Output validation** -- Prevents data leaks by ensuring your tool only returns fields you explicitly declare. Without `outputSchema`, any data in the return value passes through unvalidated, risking accidental exposure of sensitive fields (internal IDs, tokens, PII). +2. **CodeCall plugin compatibility** -- The CodeCall plugin uses `outputSchema` to understand what a tool returns, enabling correct VM-based orchestration and pass-by-reference. Tools without `outputSchema` degrade CodeCall's ability to chain results. +3. **Type safety** -- The `Out` generic on `ToolContext` is inferred from `outputSchema`, giving you compile-time guarantees that `execute()` returns the correct shape. + +```typescript +@Tool({ + name: 'get_weather', + description: 'Get current weather for a location', + inputSchema: { + city: z.string().describe('City name'), + }, + // Always define outputSchema to validate output and prevent data leaks + outputSchema: { + temperature: z.number(), + unit: z.enum(['celsius', 'fahrenheit']), + description: z.string(), + }, +}) +class GetWeatherTool extends ToolContext { + async execute(input: { city: string }): Promise<{ + temperature: number; + unit: 'celsius' | 'fahrenheit'; + description: string; + }> { + const response = await this.fetch(`https://api.weather.example.com/v1/current?city=${input.city}`); + const weather = await response.json(); + // Only temperature, unit, and description are returned. + // Any extra fields from the API (e.g., internalId, apiKey) are stripped by outputSchema validation. + return { + temperature: weather.temp, + unit: 'celsius', + description: weather.summary, + }; + } +} +``` + +**Why not omit outputSchema?** Without it: + +- The tool returns raw unvalidated data — any field your code accidentally includes leaks to the client +- CodeCall cannot infer return types for chaining tool calls in VM scripts +- No compile-time type checking on the return value + +Supported `outputSchema` types: + +- **Zod raw shapes** (recommended): `{ field: z.string(), count: z.number() }` — structured JSON output with validation +- **Zod schemas**: `z.object(...)`, `z.array(...)`, `z.union([...])` — for complex types +- **Primitive literals**: `'string'`, `'number'`, `'boolean'`, `'date'` — for simple returns +- **Media types**: `'image'`, `'audio'`, `'resource'`, `'resource_link'` — for binary/link content +- **Arrays**: `['string', 'image']` for multi-content responses + +## Dependency Injection + +Access providers registered in the scope using `this.get(token)` (throws if not found) or `this.tryGet(token)` (returns `undefined` if not found). + +```typescript +import type { Token } from '@frontmcp/di'; + +interface DatabaseService { + query(sql: string, params: unknown[]): Promise; +} +const DATABASE: Token = Symbol('database'); + +@Tool({ + name: 'run_query', + description: 'Execute a database query', + inputSchema: { + sql: z.string().describe('SQL query to execute'), + }, +}) +class RunQueryTool extends ToolContext { + async execute(input: { sql: string }) { + const db = this.get(DATABASE); // throws if DATABASE not registered + const rows = await db.query(input.sql, []); + return { rows, count: rows.length }; + } +} +``` + +Use `this.tryGet(token)` when the dependency is optional: + +```typescript +async execute(input: { data: string }) { + const cache = this.tryGet(CACHE); // returns undefined if not registered + if (cache) { + const cached = await cache.get(input.data); + if (cached) return cached; + } + // proceed without cache +} +``` + +## Error Handling + +Use `this.fail(err)` to abort execution and trigger the error flow. The method throws internally and never returns. + +```typescript +@Tool({ + name: 'delete_record', + description: 'Delete a record by ID', + inputSchema: { + id: z.string().uuid().describe('Record UUID'), + }, +}) +class DeleteRecordTool extends ToolContext { + async execute(input: { id: string }) { + const record = await this.findRecord(input.id); + if (!record) { + this.fail(new Error(`Record not found: ${input.id}`)); + } + + await this.deleteRecord(record); + return `Record ${input.id} deleted successfully`; + } + + private async findRecord(id: string) { + return null; + } + + private async deleteRecord(record: unknown) { + // delete implementation + } +} +``` + +For MCP-specific errors, use error classes with JSON-RPC codes: + +```typescript +import { ResourceNotFoundError, PublicMcpError, MCP_ERROR_CODES } from '@frontmcp/sdk'; + +this.fail(new ResourceNotFoundError(`Record ${input.id}`)); +``` + +## Progress and Notifications + +Use `this.notify(message, level?)` to send log-level notifications and `this.respondProgress(value, total?)` to send progress updates to the client. + +```typescript +@Tool({ + name: 'batch_process', + description: 'Process a batch of items', + inputSchema: { + items: z.array(z.string()).min(1).describe('Items to process'), + }, +}) +class BatchProcessTool extends ToolContext { + async execute(input: { items: string[] }) { + this.mark('validation'); + this.validateItems(input.items); + + this.mark('processing'); + const results: string[] = []; + for (let i = 0; i < input.items.length; i++) { + await this.respondProgress(i + 1, input.items.length); + const result = await this.processItem(input.items[i]); + results.push(result); + } + + this.mark('complete'); + await this.notify(`Processed ${results.length} items`, 'info'); + return { processed: results.length, results }; + } + + private validateItems(items: string[]) { + /* ... */ + } + private async processItem(item: string): Promise { + return item; + } +} +``` + +## Tool Annotations + +Provide behavioral hints to clients using `annotations`. These hints help clients decide how to present and gate tool usage. + +```typescript +@Tool({ + name: 'web_search', + description: 'Search the web', + inputSchema: { + query: z.string(), + }, + annotations: { + title: 'Web Search', + readOnlyHint: true, + openWorldHint: true, + }, +}) +class WebSearchTool extends ToolContext { + async execute(input: { query: string }) { + return await this.performSearch(input.query); + } + + private async performSearch(query: string) { + return []; + } +} +``` + +Annotation fields: + +- `title` -- Human-readable title for the tool +- `readOnlyHint` -- Tool does not modify its environment (default: false) +- `destructiveHint` -- Tool may perform destructive updates (default: true, meaningful only when readOnlyHint is false) +- `idempotentHint` -- Calling repeatedly with same args has no additional effect (default: false) +- `openWorldHint` -- Tool interacts with external entities (default: true) + +## Function-Style Builder + +For simple tools that do not need a class, use the `tool()` function builder. It returns a value you register the same way as a class tool. + +```typescript +import { tool } from '@frontmcp/sdk'; +import { z } from 'zod'; + +const AddNumbers = tool({ + name: 'add_numbers', + description: 'Add two numbers', + inputSchema: { + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }, + outputSchema: 'number', +})((input) => { + return input.a + input.b; +}); +``` + +The callback receives `(input, ctx)` where `ctx` provides access to the same context methods (`get`, `tryGet`, `fail`, `mark`, `fetch`, `notify`, `respondProgress`). + +Register it the same way as a class tool: `tools: [AddNumbers]`. + +## Remote and ESM Loading + +Load tools from external modules or remote URLs without importing them directly. + +**ESM loading** -- load a tool from an ES module: + +```typescript +const RemoteTool = Tool.esm('@my-org/tools@^1.0.0', 'MyTool', { + description: 'A tool loaded from an ES module', +}); +``` + +**Remote loading** -- load a tool from a remote URL: + +```typescript +const CloudTool = Tool.remote('https://example.com/tools/cloud-tool', 'CloudTool', { + description: 'A tool loaded from a remote server', +}); +``` + +Both return values that can be registered in `tools: [RemoteTool, CloudTool]`. + +## Registration + +Add tool classes (or function-style tools) to the `tools` array in `@FrontMcp` or `@App`. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'my-app', + tools: [GreetUserTool, SearchDocumentsTool, AddNumbers], +}) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + tools: [RunQueryTool], // can also register tools directly on the server +}) +class MyServer {} +``` + +## Nx Generator + +Scaffold a new tool using the Nx generator: + +```bash +nx generate @frontmcp/nx:tool +``` + +This creates the tool file, spec file, and updates barrel exports. + +## Rate Limiting and Concurrency + +Protect tools with throttling controls: + +```typescript +@Tool({ + name: 'expensive_operation', + description: 'An expensive operation that should be rate limited', + inputSchema: { + data: z.string(), + }, + rateLimit: { maxRequests: 10, windowMs: 60_000 }, + concurrency: { maxConcurrent: 2 }, + timeout: { executeMs: 30_000 }, +}) +class ExpensiveOperationTool extends ToolContext { + async execute(input: { data: string }) { + // At most 10 calls per minute, 2 concurrent, 30s timeout + return await this.heavyComputation(input.data); + } + + private async heavyComputation(data: string) { + return data; + } +} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------- | ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------- | +| Input schema | `inputSchema: { name: z.string() }` (raw shape) | `inputSchema: z.object({ name: z.string() })` | Framework wraps in `z.object()` internally | +| Output schema | Always define `outputSchema` | Omit `outputSchema` | Prevents data leaks and enables CodeCall chaining | +| DI resolution | `this.get(TOKEN)` with proper error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear error; non-null assertions mask failures | +| Error handling | `this.fail(new ResourceNotFoundError(...))` | `throw new Error(...)` | `this.fail` triggers the error flow with MCP error codes | +| Tool naming | `snake_case` names: `get_weather` | `camelCase` or `PascalCase`: `getWeather` | MCP protocol convention for tool names | + +## Verification Checklist + +### Configuration + +- [ ] Tool class extends `ToolContext` and implements `execute()` +- [ ] `@Tool` decorator has `name`, `description`, and `inputSchema` +- [ ] `outputSchema` is defined to validate and restrict output fields +- [ ] Tool is registered in `tools` array of `@App` or `@FrontMcp` + +### Runtime + +- [ ] Tool appears in `tools/list` MCP response +- [ ] Valid input returns expected output +- [ ] Invalid input returns Zod validation error (not a crash) +- [ ] `this.fail()` triggers proper MCP error response +- [ ] DI dependencies resolve correctly via `this.get()` + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------- | +| Tool not appearing in `tools/list` | Not registered in `tools` array | Add tool class to `@App` or `@FrontMcp` `tools` array | +| Zod validation error on valid input | Using `z.object()` wrapper in `inputSchema` | Use raw shape: `{ field: z.string() }` not `z.object({ field: z.string() })` | +| `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` | +| Output contains unexpected fields | No `outputSchema` defined | Add `outputSchema` to strip unvalidated fields from response | +| Tool times out | No timeout configured for long operation | Add `timeout: { executeMs: 30_000 }` to `@Tool` options | + +## Reference + +- [Tools Documentation](https://docs.agentfront.dev/frontmcp/servers/tools) +- Related skills: `create-resource`, `create-prompt`, `configure-throttle`, `create-agent` diff --git a/libs/skills/catalog/frontmcp-development/references/create-workflow.md b/libs/skills/catalog/frontmcp-development/references/create-workflow.md new file mode 100644 index 00000000..001c521a --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-workflow.md @@ -0,0 +1,751 @@ +# Creating Workflows + +Workflows connect multiple jobs into managed execution pipelines with step dependencies, conditions, and triggers. A workflow defines a directed acyclic graph (DAG) of steps where each step runs a named job, and the framework handles ordering, parallelism, error propagation, and trigger management. + +## When to Use This Skill + +### Must Use + +- Orchestrating multiple jobs in a defined order with explicit step dependencies (e.g., build then test then deploy) +- Building execution pipelines that require conditional branching, parallel fan-out, or diamond dependency patterns +- Defining webhook- or event-triggered multi-step automation that the framework manages end to end + +### Recommended + +- CI/CD pipelines, data-processing ETL flows, or approval chains that combine three or more jobs +- Multi-stage provisioning sequences where steps need `continueOnError` or per-step retry policies +- Replacing hand-rolled orchestration code with a declarative DAG of job steps + +### Skip When + +- You only need a single background task with no inter-step dependencies (see `create-job`) +- You need real-time, AI-guided sequential tool calls rather than pre-declared steps (see `create-skill-with-tools`) +- You are building a conversational prompt template with no execution logic (see `create-prompt`) + +> **Decision:** Use this skill when you need a declarative, multi-step pipeline of jobs with dependency ordering, conditions, and managed error propagation. + +## Class-Based Pattern + +Create a class decorated with `@Workflow`. The decorator requires `name` and `steps` (at least one step). + +### WorkflowMetadata Fields + +| Field | Type | Required | Default | Description | +| ---------------- | ---------------------------------- | ----------- | ----------------- | ----------------------------------------------------- | +| `name` | `string` | Yes | -- | Unique workflow name | +| `steps` | `WorkflowStep[]` | Yes (min 1) | -- | Array of step definitions | +| `description` | `string` | No | -- | Human-readable description | +| `trigger` | `'manual' \| 'webhook' \| 'event'` | No | `'manual'` | How the workflow is initiated | +| `webhook` | `WebhookConfig` | No | -- | Webhook configuration (when trigger is `'webhook'`) | +| `timeout` | `number` | No | `600000` (10 min) | Maximum total workflow execution time in milliseconds | +| `maxConcurrency` | `number` | No | `5` | Maximum number of steps running in parallel | +| `permissions` | `WorkflowPermissions` | No | -- | Access control configuration | + +### WorkflowStep Fields + +| Field | Type | Required | Description | +| ----------------- | ------------------------------------------ | -------- | --------------------------------------------------------------------------- | +| `id` | `string` | Yes | Unique step identifier within the workflow | +| `jobName` | `string` | Yes | Name of the registered job to run | +| `input` | `object \| (steps: StepResults) => object` | No | Static input object or function that receives previous step results | +| `dependsOn` | `string[]` | No | Array of step IDs that must complete before this step runs | +| `condition` | `(steps: StepResults) => boolean` | No | Predicate that determines if the step should run | +| `continueOnError` | `boolean` | No | If `true`, workflow continues even if this step fails | +| `timeout` | `number` | No | Per-step timeout in milliseconds (overrides workflow timeout for this step) | +| `retry` | `RetryPolicy` | No | Per-step retry policy (overrides the job's retry policy for this step) | + +### Basic Example + +```typescript +import { Workflow } from '@frontmcp/sdk'; + +@Workflow({ + name: 'deploy-pipeline', + description: 'Build, test, and deploy a service', + steps: [ + { + id: 'build', + jobName: 'build-project', + input: { target: 'production', optimize: true }, + }, + { + id: 'test', + jobName: 'run-tests', + input: { suite: 'all', coverage: true }, + dependsOn: ['build'], + }, + { + id: 'deploy', + jobName: 'deploy-to-env', + input: (steps) => ({ + artifact: steps.get('build').outputs.artifactUrl, + environment: 'production', + }), + dependsOn: ['test'], + }, + ], +}) +class DeployPipeline {} +``` + +## Step Dependencies and DAG Execution + +Steps form a directed acyclic graph (DAG) based on their `dependsOn` declarations. The framework: + +1. Identifies steps with no dependencies and runs them in parallel (up to `maxConcurrency`) +2. As each step completes, checks which dependent steps have all their dependencies satisfied +3. Runs newly unblocked steps in parallel +4. Continues until all steps complete or a step fails (unless `continueOnError` is set) + +### Parallel Steps + +Steps without mutual dependencies run concurrently: + +```typescript +@Workflow({ + name: 'data-validation-pipeline', + description: 'Validate data from multiple sources in parallel, then merge', + maxConcurrency: 3, + steps: [ + // These three steps have no dependencies -- they run in parallel + { + id: 'validate-users', + jobName: 'validate-dataset', + input: { dataset: 'users', rules: ['no-nulls', 'email-format'] }, + }, + { + id: 'validate-orders', + jobName: 'validate-dataset', + input: { dataset: 'orders', rules: ['no-nulls', 'positive-amounts'] }, + }, + { + id: 'validate-products', + jobName: 'validate-dataset', + input: { dataset: 'products', rules: ['no-nulls', 'unique-sku'] }, + }, + // This step depends on all three -- runs after all complete + { + id: 'merge-results', + jobName: 'merge-validations', + dependsOn: ['validate-users', 'validate-orders', 'validate-products'], + input: (steps) => ({ + userReport: steps.get('validate-users').outputs, + orderReport: steps.get('validate-orders').outputs, + productReport: steps.get('validate-products').outputs, + }), + }, + ], +}) +class DataValidationPipeline {} +``` + +### Diamond Dependencies + +Steps can share dependencies, forming diamond patterns: + +```typescript +@Workflow({ + name: 'build-and-publish', + description: 'Build artifacts and publish to multiple registries', + steps: [ + { id: 'compile', jobName: 'compile-source', input: { target: 'es2022' } }, + { + id: 'publish-npm', + jobName: 'publish-to-registry', + dependsOn: ['compile'], + input: (steps) => ({ artifact: steps.get('compile').outputs.bundlePath, registry: 'npm' }), + }, + { + id: 'publish-docker', + jobName: 'publish-to-registry', + dependsOn: ['compile'], + input: (steps) => ({ artifact: steps.get('compile').outputs.bundlePath, registry: 'docker' }), + }, + { + id: 'notify', + jobName: 'send-notification', + dependsOn: ['publish-npm', 'publish-docker'], + input: (steps) => ({ + message: `Published to npm (${steps.get('publish-npm').outputs.version}) and Docker (${steps.get('publish-docker').outputs.tag})`, + }), + }, + ], +}) +class BuildAndPublish {} +``` + +## Dynamic Input from Previous Steps + +Use a function for `input` to pass data from completed steps. The function receives a `StepResults` map where each entry contains the step's state and outputs. + +```typescript +{ + id: 'transform', + jobName: 'transform-data', + dependsOn: ['extract'], + input: (steps) => ({ + data: steps.get('extract').outputs.records, + schema: steps.get('extract').outputs.schema, + rowCount: steps.get('extract').outputs.count, + }), +} +``` + +The `steps.get(stepId)` method returns a step result object: + +```typescript +interface StepResult { + state: 'pending' | 'running' | 'completed' | 'failed' | 'skipped'; + outputs: Record; // job output from the completed step + error?: string; // error message if the step failed + startedAt?: string; + completedAt?: string; +} +``` + +## Conditional Steps + +Use `condition` to conditionally run a step based on the results of previous steps. The condition receives the same `StepResults` map. + +```typescript +@Workflow({ + name: 'conditional-deploy', + description: 'Deploy only if tests pass and coverage meets threshold', + steps: [ + { + id: 'test', + jobName: 'run-tests', + input: { suite: 'all', coverage: true }, + }, + { + id: 'deploy', + jobName: 'deploy-to-env', + dependsOn: ['test'], + condition: (steps) => { + const testResult = steps.get('test'); + return testResult.state === 'completed' && testResult.outputs.coverage >= 95; + }, + input: (steps) => ({ + artifact: steps.get('test').outputs.buildPath, + environment: 'staging', + }), + }, + { + id: 'notify-failure', + jobName: 'send-notification', + dependsOn: ['test'], + condition: (steps) => steps.get('test').state === 'failed', + input: { channel: '#alerts', message: 'Test suite failed -- deployment blocked' }, + }, + ], +}) +class ConditionalDeploy {} +``` + +When a `condition` returns `false`, the step is marked as `skipped`. Downstream steps that depend on a skipped step check their own conditions with the skipped step's state. + +## Error Handling with continueOnError + +By default, a failed step stops the entire workflow. Set `continueOnError: true` on a step to allow the workflow to proceed even if that step fails. + +```typescript +@Workflow({ + name: 'resilient-pipeline', + description: 'Pipeline that continues past non-critical failures', + steps: [ + { + id: 'extract', + jobName: 'extract-data', + input: { source: 'primary-db' }, + }, + { + id: 'enrich', + jobName: 'enrich-data', + dependsOn: ['extract'], + continueOnError: true, // enrichment is optional + input: (steps) => ({ data: steps.get('extract').outputs.records }), + }, + { + id: 'load', + jobName: 'load-data', + dependsOn: ['extract', 'enrich'], + input: (steps) => { + const enrichResult = steps.get('enrich'); + // Use enriched data if available, fall back to raw + const data = + enrichResult.state === 'completed' + ? enrichResult.outputs.enrichedRecords + : steps.get('extract').outputs.records; + return { data, destination: 'warehouse' }; + }, + }, + ], +}) +class ResilientPipeline {} +``` + +## Workflow Triggers + +### Manual (Default) + +The workflow is started by an explicit API call or MCP request: + +```typescript +@Workflow({ + name: 'manual-deploy', + description: 'Manually triggered deployment', + trigger: 'manual', + steps: [ + /* ... */ + ], +}) +class ManualDeploy {} +``` + +### Webhook + +The workflow is triggered by an incoming HTTP request. Configure the webhook path, secret, and allowed HTTP methods. + +```typescript +@Workflow({ + name: 'github-deploy', + description: 'Deploy on GitHub push events', + trigger: 'webhook', + webhook: { + path: '/webhooks/github-deploy', + secret: process.env.WEBHOOK_SECRET, + methods: ['POST'], + }, + steps: [ + { + id: 'build', + jobName: 'build-project', + input: { branch: 'main' }, + }, + { + id: 'deploy', + jobName: 'deploy-to-env', + dependsOn: ['build'], + input: (steps) => ({ + artifact: steps.get('build').outputs.artifactUrl, + environment: 'production', + }), + }, + ], +}) +class GithubDeploy {} +``` + +#### WebhookConfig Fields + +| Field | Type | Default | Description | +| --------- | ---------- | --------------------------------- | ---------------------------------------------- | +| `path` | `string` | Auto-generated from workflow name | HTTP path for the webhook endpoint | +| `secret` | `string` | -- | Shared secret for webhook signature validation | +| `methods` | `string[]` | `['POST']` | Allowed HTTP methods | + +### Event + +The workflow is triggered by an internal event emitted by the application: + +```typescript +@Workflow({ + name: 'on-user-signup', + description: 'Workflow triggered when a new user signs up', + trigger: 'event', + steps: [ + { + id: 'create-profile', + jobName: 'create-user-profile', + input: { template: 'default' }, + }, + { + id: 'send-welcome', + jobName: 'send-email', + dependsOn: ['create-profile'], + input: (steps) => ({ + to: steps.get('create-profile').outputs.email, + template: 'welcome', + }), + }, + { + id: 'setup-defaults', + jobName: 'setup-user-defaults', + dependsOn: ['create-profile'], + input: (steps) => ({ + userId: steps.get('create-profile').outputs.userId, + }), + }, + ], +}) +class OnUserSignup {} +``` + +## Function Builder + +For workflows that do not need a class, use the `workflow()` function builder: + +```typescript +import { workflow } from '@frontmcp/sdk'; + +const QuickDeploy = workflow({ + name: 'quick-deploy', + description: 'Simplified deployment workflow', + steps: [ + { + id: 'build', + jobName: 'build-project', + input: { target: 'production' }, + }, + { + id: 'deploy', + jobName: 'deploy-to-env', + dependsOn: ['build'], + input: (steps) => ({ + artifact: steps.get('build').outputs.artifactUrl, + environment: 'staging', + }), + }, + ], +}); +``` + +Register it the same way as a class workflow: `workflows: [QuickDeploy]`. + +## Registration + +Add workflow classes (or function-style workflows) to the `workflows` array in `@App`. Workflows require jobs to be enabled since each step runs a named job. + +```typescript +import { FrontMcp, App } from '@frontmcp/sdk'; + +@App({ + name: 'pipeline-app', + jobs: [BuildProjectJob, RunTestsJob, DeployToEnvJob, SendNotificationJob], + workflows: [DeployPipeline, DataValidationPipeline, QuickDeploy], +}) +class PipelineApp {} + +@FrontMcp({ + info: { name: 'pipeline-server', version: '1.0.0' }, + apps: [PipelineApp], + jobs: { + enabled: true, + store: { + redis: { + provider: 'redis', + host: 'localhost', + port: 6379, + keyPrefix: 'mcp:jobs:', + }, + }, + }, +}) +class PipelineServer {} +``` + +## Nx Generator + +Scaffold a new workflow using the Nx generator: + +```bash +nx generate @frontmcp/nx:workflow +``` + +This creates the workflow file, spec file, and updates barrel exports. + +## Complete Example: CI/CD Pipeline + +```typescript +import { Workflow, Job, JobContext, FrontMcp, App, workflow } from '@frontmcp/sdk'; +import { z } from 'zod'; + +// --- Jobs --- + +@Job({ + name: 'checkout-code', + description: 'Checkout code from repository', + inputSchema: { + repo: z.string().describe('Repository URL'), + branch: z.string().default('main'), + }, + outputSchema: { + workDir: z.string(), + commitSha: z.string(), + }, +}) +class CheckoutCodeJob extends JobContext { + async execute(input: { repo: string; branch: string }) { + this.log(`Checking out ${input.repo}@${input.branch}`); + return { workDir: '/tmp/build/workspace', commitSha: 'abc123' }; + } +} + +@Job({ + name: 'run-linter', + description: 'Run linter on codebase', + inputSchema: { + workDir: z.string(), + }, + outputSchema: { + passed: z.boolean(), + issues: z.number().int(), + }, +}) +class RunLinterJob extends JobContext { + async execute(input: { workDir: string }) { + this.log(`Linting ${input.workDir}`); + return { passed: true, issues: 0 }; + } +} + +@Job({ + name: 'run-unit-tests', + description: 'Run unit test suite', + inputSchema: { + workDir: z.string(), + coverage: z.boolean().default(true), + }, + outputSchema: { + passed: z.boolean(), + testCount: z.number().int(), + coverage: z.number(), + }, + retry: { maxAttempts: 2, backoffMs: 3000, backoffMultiplier: 1, maxBackoffMs: 3000 }, +}) +class RunUnitTestsJob extends JobContext { + async execute(input: { workDir: string; coverage: boolean }) { + this.log(`Running unit tests in ${input.workDir}`); + this.progress(50, 100, 'Tests running'); + return { passed: true, testCount: 342, coverage: 96.4 }; + } +} + +@Job({ + name: 'build-artifact', + description: 'Build production artifact', + inputSchema: { + workDir: z.string(), + commitSha: z.string(), + }, + outputSchema: { + artifactUrl: z.string().url(), + size: z.number().int(), + }, + timeout: 180000, +}) +class BuildArtifactJob extends JobContext { + async execute(input: { workDir: string; commitSha: string }) { + this.log(`Building artifact from ${input.commitSha}`); + this.progress(0, 100, 'Compiling'); + this.progress(100, 100, 'Build complete'); + return { + artifactUrl: `https://artifacts.example.com/builds/${input.commitSha}.tar.gz`, + size: 52428800, + }; + } +} + +@Job({ + name: 'deploy-artifact', + description: 'Deploy artifact to target environment', + inputSchema: { + artifactUrl: z.string().url(), + environment: z.string(), + }, + outputSchema: { + deploymentId: z.string(), + url: z.string().url(), + }, + retry: { maxAttempts: 3, backoffMs: 5000, backoffMultiplier: 2, maxBackoffMs: 30000 }, + permissions: { + actions: ['execute'], + roles: ['admin', 'deployer'], + scopes: ['deploy:write'], + }, +}) +class DeployArtifactJob extends JobContext { + async execute(input: { artifactUrl: string; environment: string }) { + this.log(`Deploying ${input.artifactUrl} to ${input.environment}`); + return { + deploymentId: 'deploy-001', + url: `https://${input.environment}.example.com`, + }; + } +} + +@Job({ + name: 'notify-team', + description: 'Send notification to the team', + inputSchema: { + channel: z.string(), + message: z.string(), + }, + outputSchema: { + sent: z.boolean(), + }, +}) +class NotifyTeamJob extends JobContext { + async execute(input: { channel: string; message: string }) { + this.log(`Notifying ${input.channel}: ${input.message}`); + return { sent: true }; + } +} + +// --- Workflow --- + +@Workflow({ + name: 'ci-cd-pipeline', + description: 'Full CI/CD pipeline: checkout, lint, test, build, deploy, notify', + trigger: 'webhook', + webhook: { + path: '/webhooks/ci-cd', + secret: process.env.CI_WEBHOOK_SECRET, + methods: ['POST'], + }, + timeout: 900000, // 15 minutes + maxConcurrency: 3, + permissions: { + actions: ['create', 'read', 'execute', 'list'], + roles: ['admin', 'ci-bot'], + }, + steps: [ + { + id: 'checkout', + jobName: 'checkout-code', + input: { repo: 'https://github.com/org/repo.git', branch: 'main' }, + }, + { + id: 'lint', + jobName: 'run-linter', + dependsOn: ['checkout'], + input: (steps) => ({ + workDir: steps.get('checkout').outputs.workDir, + }), + }, + { + id: 'test', + jobName: 'run-unit-tests', + dependsOn: ['checkout'], + input: (steps) => ({ + workDir: steps.get('checkout').outputs.workDir, + coverage: true, + }), + }, + { + id: 'build', + jobName: 'build-artifact', + dependsOn: ['lint', 'test'], + condition: (steps) => + steps.get('lint').state === 'completed' && + steps.get('lint').outputs.passed === true && + steps.get('test').state === 'completed' && + steps.get('test').outputs.passed === true && + steps.get('test').outputs.coverage >= 95, + input: (steps) => ({ + workDir: steps.get('checkout').outputs.workDir, + commitSha: steps.get('checkout').outputs.commitSha, + }), + }, + { + id: 'deploy', + jobName: 'deploy-artifact', + dependsOn: ['build'], + condition: (steps) => steps.get('build').state === 'completed', + input: (steps) => ({ + artifactUrl: steps.get('build').outputs.artifactUrl, + environment: 'staging', + }), + }, + { + id: 'notify-success', + jobName: 'notify-team', + dependsOn: ['deploy'], + condition: (steps) => steps.get('deploy').state === 'completed', + input: (steps) => ({ + channel: '#deployments', + message: `Deployed ${steps.get('deploy').outputs.deploymentId} to ${steps.get('deploy').outputs.url}`, + }), + }, + { + id: 'notify-failure', + jobName: 'notify-team', + dependsOn: ['lint', 'test'], + condition: (steps) => steps.get('lint').state === 'failed' || steps.get('test').state === 'failed', + input: { + channel: '#alerts', + message: 'CI pipeline failed -- check lint and test results', + }, + }, + ], +}) +class CiCdPipeline {} + +// --- Registration --- + +@App({ + name: 'ci-app', + jobs: [CheckoutCodeJob, RunLinterJob, RunUnitTestsJob, BuildArtifactJob, DeployArtifactJob, NotifyTeamJob], + workflows: [CiCdPipeline], +}) +class CiApp {} + +@FrontMcp({ + info: { name: 'ci-server', version: '1.0.0' }, + apps: [CiApp], + jobs: { + enabled: true, + store: { + redis: { + provider: 'redis', + host: 'localhost', + port: 6379, + keyPrefix: 'mcp:ci:', + }, + }, + }, +}) +class CiServer {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| Step dependencies | `dependsOn: ['build', 'test']` (array of step IDs) | `dependsOn: 'build'` (plain string) | `dependsOn` expects a `string[]`, not a single string | +| Dynamic input | `input: (steps) => ({ artifact: steps.get('build').outputs.url })` | `input: { artifact: buildResult.url }` (captured closure variable) | Static objects cannot reference previous step outputs; use the callback form | +| Conditional steps | `condition: (steps) => steps.get('test').state === 'completed'` | `condition: (steps) => steps.get('test').outputs` (truthy check) | Always check `.state` explicitly; outputs can be truthy even on partial failure | +| Job registration | Register all referenced jobs in the `jobs` array of `@App` | Declare `jobName` in steps without registering the job class | Steps reference jobs by name; unregistered jobs cause runtime lookup failures | +| Workflow trigger | Set `trigger: 'webhook'` and provide `webhook: { path, secret }` | Set `trigger: 'webhook'` without a `webhook` config object | Webhook trigger requires the `webhook` configuration block for path and secret | + +## Verification Checklist + +### Configuration + +- [ ] `@Workflow` decorator has `name` and at least one step in `steps` +- [ ] Every `jobName` in steps matches a registered `@Job` name +- [ ] `dependsOn` arrays reference valid step `id` values within the same workflow +- [ ] No circular dependencies exist in the step DAG + +### Runtime + +- [ ] Workflow appears in the server's workflow registry after startup +- [ ] Steps with no dependencies execute in parallel (up to `maxConcurrency`) +- [ ] Conditional steps are correctly skipped or executed based on prior step results +- [ ] `continueOnError: true` steps allow downstream steps to proceed on failure +- [ ] Webhook-triggered workflows respond to incoming HTTP requests + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Step never executes | `dependsOn` references a step ID that does not exist | Verify all `dependsOn` entries match actual step `id` values in the workflow | +| Workflow fails at startup with "job not found" | `jobName` references an unregistered job | Add the job class to the `jobs` array in `@App` before registering the workflow | +| Dynamic `input` callback receives undefined outputs | Dependent step was skipped or failed without `continueOnError` | Add a `condition` guard that checks `steps.get(id).state === 'completed'` before accessing outputs | +| Webhook trigger does not fire | Missing or mismatched `webhook.secret` | Ensure `webhook.secret` matches the sender's HMAC secret and `webhook.path` is correct | +| Workflow exceeds timeout | Total step execution time exceeds the default 600000 ms | Increase `timeout` at the workflow level or add per-step `timeout` overrides | + +## Reference + +- [Workflows Documentation](https://docs.agentfront.dev/frontmcp/servers/workflows) +- Related skills: `create-job`, `create-skill-with-tools`, `create-tool`, `multi-app-composition` diff --git a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md new file mode 100644 index 00000000..9586d0e1 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md @@ -0,0 +1,687 @@ +# FrontMCP Decorators - Complete Reference + +## Architecture Overview + +FrontMCP uses a hierarchical decorator system. The nesting order is: + +```text +@FrontMcp (server root) + +-- @App (application module) + +-- @Tool (MCP tool) + +-- @Resource (static MCP resource) + +-- @ResourceTemplate (parameterized resource) + +-- @Prompt (MCP prompt) + +-- @Agent (autonomous AI agent) + +-- @Skill (knowledge/workflow package) + +-- @Plugin (lifecycle plugin) + +-- @Provider (DI provider) + +-- @Adapter (external source adapter) + +-- @Job (long-running job) + +-- @Workflow (multi-step workflow) + +-- @Flow (custom flow) + +-- @Hook (@Will, @Did, @Stage, @Around) +``` + +--- + +## When to Use This Skill + +### Must Use + +- You are building a new FrontMCP server and need to choose the correct decorator for each component +- You are reviewing or debugging decorator configuration and need to verify field names, types, or nesting hierarchy +- You are onboarding to the FrontMCP codebase and need a single reference for the full decorator architecture + +### Recommended + +- You are adding a new capability (tool, resource, prompt, agent, skill) to an existing server and want to confirm the correct decorator signature +- You are designing a plugin or adapter and need to understand how it integrates with the decorator hierarchy +- You are refactoring an app's module structure and need to verify which decorators belong in `@App` vs `@FrontMcp` + +### Skip When + +- You only need to write business logic inside an existing tool or resource (see `create-tool` reference) +- You are configuring authentication or session management without changing decorators (see `configure-auth` reference) +- You are working on CI/CD, deployment, or infrastructure that does not involve decorator choices + +> **Decision:** Use this skill whenever you need to look up, choose, or validate a FrontMCP decorator -- skip it when the decorator is already chosen and you are only implementing internal logic. + +--- + +## 1. @FrontMcp + +**Purpose:** Declares the root MCP server and its global configuration. + +**When to use:** Once per server, on the top-level bootstrap class. + +**Key fields:** + +| Field | Description | +| --------------- | ------------------------------------------------------------------------------- | +| `info` | Server name, version, and description | +| `apps` | Array of `@App` classes to mount | +| `redis?` | Redis connection options | +| `plugins?` | Global plugins | +| `providers?` | Global DI providers | +| `tools?` | Standalone tools (outside apps) | +| `resources?` | Standalone resources | +| `skills?` | Standalone skills | +| `skillsConfig?` | Skills feature configuration (enabled, cache, auth) | +| `transport?` | Transport preset ('modern', 'legacy', 'stateless-api', 'full') or config object | +| `auth?` | Authentication mode and OAuth configuration (AuthOptionsInput) | +| `http?` | HTTP server options (port, host, cors) | +| `logging?` | Logging configuration | +| `elicitation?` | Elicitation store config | +| `sqlite?` | SQLite storage config | +| `pubsub?` | Pub/sub configuration | +| `jobs?` | Job scheduler config | +| `throttle?` | Rate limiting config | +| `pagination?` | Pagination defaults | +| `ui?` | UI configuration | + +```typescript +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MainApp], + transport: 'modern', // Valid presets: 'modern', 'legacy', 'stateless-api', 'full' + http: { port: 3000 }, + plugins: [RememberPlugin], + skillsConfig: { enabled: true }, +}) +class MyServer {} +``` + +--- + +## 2. @App + +**Purpose:** Groups related tools, resources, prompts, agents, and skills into an application module. + +**When to use:** To organize your server into logical modules. Every server has at least one app. + +**Key fields:** + +| Field | Description | +| ------------- | ----------------------------------------------------- | +| `name` | Application name | +| `tools?` | Array of tool classes or function-built tools | +| `resources?` | Array of resource classes or function-built resources | +| `prompts?` | Array of prompt classes or function-built prompts | +| `agents?` | Array of agent classes | +| `skills?` | Array of skill definitions | +| `plugins?` | App-scoped plugins | +| `providers?` | App-scoped DI providers | +| `adapters?` | External source adapters | +| `auth?` | Auth configuration | +| `standalone?` | Whether the app runs independently | +| `jobs?` | Job definitions | +| `workflows?` | Workflow definitions | + +```typescript +import { App } from '@frontmcp/sdk'; + +@App({ + name: 'analytics', + tools: [QueryTool, ReportTool], + resources: [DashboardResource], + prompts: [SummaryPrompt], + providers: [DatabaseProvider], +}) +class AnalyticsApp {} +``` + +--- + +## 3. @Tool + +**Purpose:** Defines an MCP tool that an LLM can invoke to perform actions. + +**When to use:** When you need the LLM to execute a function, query data, or trigger side effects. + +**Key fields:** + +| Field | Description | +| -------------------- | ---------------------------------------------------------- | +| `name` | Tool name (used in MCP protocol) | +| `description` | Human-readable description for the LLM | +| `inputSchema` | Zod raw shape defining input parameters | +| `outputSchema?` | Zod schema for output validation | +| `annotations?` | MCP tool annotations (readOnlyHint, destructiveHint, etc.) | +| `tags?` | Categorization tags | +| `hideFromDiscovery?` | Hide from tool listing | +| `concurrency?` | Max concurrent executions | +| `rateLimit?` | Rate limiting configuration | +| `timeout?` | Execution timeout in ms | +| `ui?` | UI rendering hints | + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'search_users', + description: 'Search for users by name or email', + inputSchema: { + query: z.string().describe('Search query'), + limit: z.number().optional().default(10), + }, +}) +class SearchUsersTool extends ToolContext { + async execute(input: { query: string; limit: number }) { + const users = await this.get(UserService).search(input.query, input.limit); + return { users }; + } +} +``` + +--- + +## 4. @Prompt + +**Purpose:** Defines an MCP prompt template that generates structured messages for the LLM. + +**When to use:** When you want to expose reusable prompt templates with typed arguments. + +**Key fields:** + +| Field | Description | +| -------------- | ------------------------------------------------------------------- | +| `name` | Prompt name | +| `description?` | What this prompt does | +| `arguments?` | Array of argument definitions (`{ name, description?, required? }`) | + +```typescript +import { Prompt, PromptContext } from '@frontmcp/sdk'; + +@Prompt({ + name: 'code_review', + description: 'Generate a code review for the given code', + arguments: [ + { name: 'code', description: 'The code to review', required: true }, + { name: 'language', description: 'Programming language' }, + ], +}) +class CodeReviewPrompt extends PromptContext { + async execute(args: { code: string; language?: string }) { + return { + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${args.language ?? ''} code:\n\n${args.code}`, + }, + }, + ], + }; + } +} +``` + +--- + +## 5. @Resource + +**Purpose:** Exposes a static MCP resource identified by a fixed URI. + +**When to use:** When you need to expose data at a known, unchanging URI (e.g., config files, system status). + +**Key fields:** + +| Field | Description | +| -------------- | -------------------------------------------- | +| `name` | Resource name | +| `uri` | Fixed URI (e.g., `config://app/settings`) | +| `description?` | What this resource provides | +| `mimeType?` | Content MIME type (e.g., `application/json`) | + +```typescript +import { Resource, ResourceContext } from '@frontmcp/sdk'; + +@Resource({ + name: 'app_config', + uri: 'config://app/settings', + description: 'Current application settings', + mimeType: 'application/json', +}) +class AppConfigResource extends ResourceContext { + async read() { + const config = await this.get(ConfigService).getAll(); + return { contents: [{ uri: this.uri, text: JSON.stringify(config) }] }; + } +} +``` + +--- + +## 6. @ResourceTemplate + +**Purpose:** Exposes a parameterized MCP resource with URI pattern matching. + +**When to use:** When resources are identified by dynamic parameters (e.g., user profiles, documents by ID). + +**Key fields:** + +| Field | Description | +| -------------- | --------------------------------------------------------------- | +| `name` | Resource template name | +| `uriTemplate` | URI template with parameters (e.g., `users://{userId}/profile`) | +| `description?` | What this resource provides | +| `mimeType?` | Content MIME type | + +```typescript +import { ResourceTemplate, ResourceContext } from '@frontmcp/sdk'; + +@ResourceTemplate({ + name: 'user_profile', + uriTemplate: 'users://{userId}/profile', + description: 'User profile by ID', + mimeType: 'application/json', +}) +class UserProfileResource extends ResourceContext { + async read(uri: string, params: { userId: string }) { + const user = await this.get(UserService).findById(params.userId); + return { contents: [{ uri, text: JSON.stringify(user) }] }; + } +} +``` + +--- + +## 7. @Agent + +**Purpose:** Defines an autonomous AI agent that uses LLMs to accomplish tasks, optionally with tools and sub-agents. + +**When to use:** When you need an autonomous entity that reasons, plans, and executes multi-step tasks using LLMs. + +**Key fields:** + +| Field | Description | +| --------------- | ------------------------------------------------------ | +| `name` | Agent name | +| `description` | What this agent does | +| `llm` | LLM configuration (model, provider, temperature, etc.) | +| `inputSchema?` | Zod raw shape for agent input | +| `outputSchema?` | Zod schema for structured output | +| `tools?` | Tools available to this agent | +| `agents?` | Sub-agents for delegation | +| `exports?` | What capabilities to expose externally | +| `swarm?` | Multi-agent swarm configuration | + +```typescript +import { Agent, AgentContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Agent({ + name: 'research_agent', + description: 'Researches topics and produces summaries', + llm: { model: 'claude-sonnet-4-20250514', provider: 'anthropic' }, + inputSchema: { + topic: z.string().describe('Topic to research'), + }, + tools: [WebSearchTool, SummarizeTool], +}) +class ResearchAgent extends AgentContext { + async execute(input: { topic: string }) { + return this.run(`Research and summarize: ${input.topic}`); + } +} +``` + +--- + +## 8. @Skill + +**Purpose:** Packages knowledge, instructions, and tools into a reusable workflow unit that LLMs can discover and follow. + +**When to use:** When you want to bundle a set of instructions and tools into a cohesive capability that an LLM can activate. + +**Key fields:** + +| Field | Description | +| ----------------- | ------------------------------------------------------ | +| `name` | Skill name | +| `description` | What this skill enables | +| `instructions` | Detailed instructions the LLM should follow | +| `tools?` | Tools bundled with this skill | +| `parameters?` | Configurable parameters | +| `examples?` | Usage examples | +| `visibility?` | Where skill is visible: `'mcp'`, `'http'`, or `'both'` | +| `toolValidation?` | Validation rules for tool usage | + +```typescript +import { Skill } from '@frontmcp/sdk'; + +@Skill({ + name: 'code_migration', + description: 'Guides migration of code between frameworks', + instructions: ` + 1. Analyze the source codebase structure + 2. Identify framework-specific patterns + 3. Generate migration plan + 4. Apply transformations using the provided tools + `, + tools: [AnalyzeTool, TransformTool, ValidateTool], + visibility: 'both', +}) +class CodeMigrationSkill {} +``` + +--- + +## 9. @Plugin + +**Purpose:** Adds lifecycle hooks, DI providers, and context extensions to the server. + +**When to use:** When you need cross-cutting concerns (logging, caching, session memory) that span multiple tools. + +**Key fields:** + +| Field | Description | +| -------------------- | --------------------------------------------------------------- | +| `name` | Plugin name | +| `providers?` | DI providers this plugin registers | +| `contextExtensions?` | Extensions to add to execution contexts (e.g., `this.remember`) | +| `tools?` | Tools provided by this plugin | + +```typescript +import { Plugin } from '@frontmcp/sdk'; + +@Plugin({ + name: 'audit-log', + providers: [AuditLogProvider], + contextExtensions: [installAuditExtension], +}) +class AuditPlugin {} +``` + +--- + +## 10. @Adapter + +**Purpose:** Integrates an external API or data source, converting it into FrontMCP tools and resources. + +**When to use:** When you want to auto-generate MCP tools/resources from an external OpenAPI spec, GraphQL schema, or other source. + +**Key fields:** + +| Field | Description | +| ------ | ------------ | +| `name` | Adapter name | + +```typescript +import { Adapter } from '@frontmcp/sdk'; + +@Adapter({ name: 'github-api' }) +class GitHubAdapter { + async connect() { + // Load OpenAPI spec and generate tools + } +} +``` + +--- + +## 11. @Provider + +**Purpose:** Registers a dependency injection provider in the FrontMCP DI container. + +**When to use:** When you need injectable services, configuration, or factories available via `this.get(Token)`. + +**Key fields:** + +| Field | Description | +| ------------ | --------------------------------------------------------------- | +| `name` | Provider name | +| `provide` | Injection token | +| `useClass` | Class to instantiate (pick one of useClass/useValue/useFactory) | +| `useValue` | Static value to inject | +| `useFactory` | Factory function for dynamic creation | + +```typescript +import { Provider } from '@frontmcp/sdk'; + +@Provider({ + name: 'database', + provide: DatabaseToken, + useFactory: () => new DatabaseClient(process.env.DB_URL), +}) +class DatabaseProvider {} +``` + +--- + +## 12. @Flow + +**Purpose:** Defines a custom request/response flow with a multi-stage processing plan. + +**When to use:** When you need complex multi-step request processing beyond simple tool execution (e.g., validation, transformation, approval chains). + +**Key fields:** + +| Field | Description | +| -------------- | ----------------------------------- | +| `name` | Flow name | +| `plan` | Array of stages to execute in order | +| `inputSchema` | Zod schema for flow input | +| `outputSchema` | Zod schema for flow output | +| `access` | Access control configuration | + +```typescript +import { Flow } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Flow({ + name: 'approval-flow', + plan: [ValidateStage, EnrichStage, ApproveStage, ExecuteStage], + inputSchema: z.object({ action: z.string(), target: z.string() }), + outputSchema: z.object({ approved: z.boolean(), result: z.unknown() }), + access: { roles: ['admin'] }, +}) +class ApprovalFlow {} +``` + +--- + +## 13. @Job + +**Purpose:** Declares a long-running or scheduled background job. + +**When to use:** When you need recurring tasks (cron), background processing, or deferred work. + +**Key fields:** + +| Field | Description | +| ------------- | --------------------------------------------------------- | +| `name` | Job name | +| `description` | What the job does | +| `schedule?` | Cron expression (e.g., `'0 */6 * * *'` for every 6 hours) | + +```typescript +import { Job, JobContext } from '@frontmcp/sdk'; + +@Job({ + name: 'sync_data', + description: 'Synchronize data from external sources', + schedule: '0 */6 * * *', +}) +class SyncDataJob extends JobContext { + async execute() { + await this.get(SyncService).runFullSync(); + } +} +``` + +--- + +## 14. @Workflow + +**Purpose:** Orchestrates a multi-step workflow composed of sequential or parallel steps. + +**When to use:** When you need to coordinate multiple jobs or actions in a defined order with error handling and rollback. + +**Key fields:** + +| Field | Description | +| ------------- | ----------------------------------- | +| `name` | Workflow name | +| `description` | What this workflow accomplishes | +| `steps` | Array of step definitions (ordered) | + +```typescript +import { Workflow } from '@frontmcp/sdk'; + +@Workflow({ + name: 'deploy_pipeline', + description: 'Full deployment pipeline', + steps: [ + { name: 'build', job: BuildJob }, + { name: 'test', job: TestJob }, + { name: 'deploy', job: DeployJob }, + ], +}) +class DeployPipeline {} +``` + +--- + +## 15. @Hook Decorators (@Will, @Did, @Stage, @Around) + +**Purpose:** Attach lifecycle hooks to flows, allowing interception at different points. + +**When to use:** When you need to run logic before, after, at a specific stage of, or wrapping around a flow execution. + +**Variants:** + +| Decorator | Timing | Description | +| --------- | -------- | ----------------------------------------- | +| `@Will` | Before | Runs before the flow executes | +| `@Did` | After | Runs after the flow completes | +| `@Stage` | During | Runs at a specific stage in the flow plan | +| `@Around` | Wrapping | Wraps the flow, controlling execution | + +```typescript +import { Will, Did, Stage, Around, HookContext } from '@frontmcp/sdk'; + +class AuditHooks { + @Will('tools:call-tool') + async beforeToolCall(ctx: HookContext) { + ctx.state.set('startTime', Date.now()); + } + + @Did('tools:call-tool') + async afterToolCall(ctx: HookContext) { + const duration = Date.now() - ctx.state.get('startTime'); + await this.get(AuditService).log({ tool: ctx.toolName, duration }); + } + + @Around('resources:read-resource') + async cacheResource(ctx: HookContext, next: () => Promise) { + const cached = await this.get(CacheService).get(ctx.uri); + if (cached) { + ctx.respond(cached); + return; + } + await next(); + } +} +``` + +--- + +## Quick Reference Table + +| Decorator | Extends | Registered In | Purpose | +| --------------------------- | ----------------- | ---------------- | ------------------------ | +| `@FrontMcp` | - | Root | Server configuration | +| `@App` | - | `@FrontMcp.apps` | Module grouping | +| `@Tool` | `ToolContext` | `@App.tools` | Executable action | +| `@Prompt` | `PromptContext` | `@App.prompts` | Prompt template | +| `@Resource` | `ResourceContext` | `@App.resources` | Static data | +| `@ResourceTemplate` | `ResourceContext` | `@App.resources` | Parameterized data | +| `@Agent` | `AgentContext` | `@App.agents` | Autonomous AI agent | +| `@Skill` | - | `@App.skills` | Knowledge package | +| `@Plugin` | - | `@App.plugins` | Cross-cutting concern | +| `@Adapter` | - | `@App.adapters` | External integration | +| `@Provider` | - | `@App.providers` | DI binding | +| `@Flow` | - | `@App` | Custom flow | +| `@Job` | `JobContext` | `@App.jobs` | Background task | +| `@Workflow` | - | `@App.workflows` | Multi-step orchestration | +| `@Will/@Did/@Stage/@Around` | - | Entry class | Lifecycle hooks | + +--- + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Grouping tools into modules | Place tools inside `@App({ tools: [...] })` | Register tools directly in `@FrontMcp({ tools: [...] })` for large servers | Apps provide logical grouping, scoped providers, and isolation; standalone tools in `@FrontMcp` are only appropriate for small servers or global utilities | +| Exposing data to the LLM | Use `@Resource` for fixed URIs, `@ResourceTemplate` for parameterized URIs | Using `@Tool` to return static data that never changes | Resources are the MCP-standard way to expose readable data; tools are for actions with side effects or dynamic computation | +| Cross-cutting concerns | Create a `@Plugin` with providers and context extensions | Adding logging/caching logic directly inside every tool's `execute()` method | Plugins centralize shared behavior, reduce duplication, and can be reused across servers | +| Background processing | Use `@Job` with a cron schedule for recurring work | Using `setTimeout` or manual polling inside a tool | Jobs integrate with the scheduler, support persistence, and are visible in server diagnostics | +| Multi-step orchestration | Use `@Workflow` with ordered steps referencing `@Job` classes | Chaining multiple tool calls manually from the LLM | Workflows provide built-in ordering, error handling, and rollback semantics | +| Injecting services | Use `@Provider` with `useFactory`/`useClass` and access via `this.get(Token)` | Importing singletons directly or using global state | DI providers support testability, lifecycle management, and per-scope isolation | + +--- + +## Verification Checklist + +### Structure + +- [ ] Server has exactly one `@FrontMcp` decorated class +- [ ] Every `@App` is listed in the `@FrontMcp({ apps: [...] })` array +- [ ] Each tool, resource, prompt, agent, and skill is registered in an `@App` (or in `@FrontMcp` for standalone use) + +### Decorator Fields + +- [ ] Every `@Tool` has `name`, `description`, and `inputSchema` defined +- [ ] Every `@Resource` has `name` and `uri` with a valid scheme (e.g., `config://`, `file://`) +- [ ] Every `@ResourceTemplate` has `uriTemplate` with `{param}` placeholders matching the `read()` params argument +- [ ] Every `@Prompt` has `name` and at least one argument when it accepts input +- [ ] Every `@Agent` has `name`, `description`, and `llm` configuration + +### Inheritance + +- [ ] Tool classes extend `ToolContext` and implement `execute()` +- [ ] Prompt classes extend `PromptContext` and implement `execute()` +- [ ] Resource classes extend `ResourceContext` and implement `read()` +- [ ] Agent classes extend `AgentContext` and implement `execute()` +- [ ] Job classes extend `JobContext` and implement `execute()` + +### Hooks + +- [ ] Hook flow strings match valid flows (e.g., `tools:call-tool`, `resources:read-resource`) +- [ ] `@Around` hooks call `await next()` to continue the chain (unless intentionally short-circuiting) +- [ ] Hooks do not mutate `rawInput` -- use `ctx.state.set()` for flow state + +### DI and Plugins + +- [ ] All `@Provider` entries specify exactly one of `useClass`, `useValue`, or `useFactory` +- [ ] Plugins are registered in `@App({ plugins: [...] })` or `@FrontMcp({ plugins: [...] })` +- [ ] Context extensions installed by plugins match the module augmentation declarations + +--- + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Tool does not appear in `tools/list` MCP response | Tool class is not registered in any `@App({ tools: [...] })` or `@FrontMcp({ tools: [...] })` | Add the tool class to the `tools` array of the appropriate `@App` or `@FrontMcp` decorator | +| `this.get(Token)` throws `DependencyNotFoundError` | The provider for that token is not registered or is registered in a different app scope | Add a `@Provider` for the token in the same `@App` or in `@FrontMcp({ providers: [...] })` for global access | +| Resource returns 404 / `ResourceNotFoundError` | The `uri` in `@Resource` does not match the requested URI, or `uriTemplate` parameters are misaligned | Verify the URI string exactly matches what the client requests; for templates, confirm `{param}` names match | +| Hook never fires | The `flow` string in `@Will`/`@Did`/`@Around`/`@Stage` does not match any registered flow | Check the flow string against valid flows (e.g., `tools:call-tool`, `resources:read-resource`, `resources:list-resources`) | +| Plugin context extension is `undefined` at runtime | The plugin's `installContextExtension` function was not called, or module augmentation is missing | Ensure the plugin is registered and its context extension function runs at startup; verify the `declare module` augmentation exists | +| Agent `execute()` returns empty result | LLM configuration is missing or invalid (wrong model name, missing API key) | Verify `llm.model` and `llm.provider` in `@Agent`, and ensure the provider API key is set in environment variables | + +--- + +## Reference + +- **Official docs:** [FrontMCP Decorators Overview](https://docs.agentfront.dev/frontmcp/sdk-reference/decorators/overview) +- **Related skills:** + - `create-tool` -- step-by-step guide for building tools with `@Tool` and `ToolContext` + - `create-resource` -- patterns for `@Resource` and `@ResourceTemplate` usage + - `create-plugin` -- creating plugins with `@Plugin`, providers, and context extensions + - `configure-auth` -- authentication and session configuration (not decorator-focused) diff --git a/libs/skills/catalog/frontmcp-development/references/official-adapters.md b/libs/skills/catalog/frontmcp-development/references/official-adapters.md new file mode 100644 index 00000000..c665fc3a --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/official-adapters.md @@ -0,0 +1,194 @@ +# Official Adapters + +Adapters convert external definitions (OpenAPI specs, Lambda functions, etc.) into MCP tools, resources, and prompts automatically. + +## When to Use This Skill + +### Must Use + +- Converting an OpenAPI/Swagger specification into MCP tools automatically +- Integrating a REST API that provides a public OpenAPI spec (Petstore, GitHub, Jira, Slack) +- Setting up authentication (API key, bearer token, OAuth) for an adapter-generated API integration + +### Recommended + +- Registering multiple external APIs as namespaced tool sets in a single server +- Enabling spec polling to auto-refresh tool definitions when the upstream API changes +- Providing an inline OpenAPI spec for APIs without a hosted spec URL + +### Skip When + +- The external API has no OpenAPI spec and uses a custom protocol (see `create-adapter`) +- You need cross-cutting behavior like caching or logging (see `create-plugin` or `official-plugins`) +- You are building tools manually without an external spec (see `create-tool`) + +> **Decision:** Use this skill when you have an OpenAPI/Swagger spec and want to automatically generate MCP tools from it using `OpenApiAdapter`. + +## OpenAPI Adapter + +The primary official adapter. Converts OpenAPI/Swagger specifications into MCP tools — one tool per operation. + +### Installation + +```typescript +import { OpenApiAdapter } from '@frontmcp/adapters'; + +@App({ + name: 'MyApp', + adapters: [ + OpenApiAdapter.init({ + name: 'petstore', + url: 'https://petstore3.swagger.io/api/v3/openapi.json', + }), + ], +}) +class MyApp {} +``` + +Each OpenAPI operation becomes an MCP tool named `petstore:operationId`. + +### With Authentication + +```typescript +// API Key via static auth +OpenApiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + staticAuth: { + apiKey: process.env.API_KEY!, + }, +}); + +// API Key via additional headers +OpenApiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + additionalHeaders: { + 'X-API-Key': process.env.API_KEY!, + }, +}); + +// Bearer token via static auth +OpenApiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + staticAuth: { + jwt: process.env.API_TOKEN!, + }, +}); + +// Dynamic auth per tool using securityResolver +OpenApiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + securityResolver: (tool, ctx) => { + return { jwt: ctx.authInfo?.token }; + }, +}); +``` + +### Spec Polling + +Automatically refresh the OpenAPI spec at intervals: + +```typescript +OpenApiAdapter.init({ + name: 'evolving-api', + url: 'https://api.example.com/openapi.json', + polling: { + intervalMs: 300000, // Re-fetch every 5 minutes + }, +}); +``` + +### Inline Spec + +Provide the OpenAPI spec directly instead of fetching from URL: + +```typescript +OpenApiAdapter.init({ + name: 'my-api', + spec: { + openapi: '3.0.0', + info: { title: 'My API', version: '1.0.0' }, + paths: { ... }, + }, +}) +``` + +### Multiple Adapters + +Register adapters from different APIs in the same app: + +```typescript +@App({ + name: 'IntegrationHub', + adapters: [ + OpenApiAdapter.init({ name: 'github', url: 'https://api.github.com/openapi.json' }), + OpenApiAdapter.init({ name: 'jira', url: 'https://jira.example.com/openapi.json' }), + OpenApiAdapter.init({ name: 'slack', url: 'https://slack.com/openapi.json' }), + ], +}) +class IntegrationHub {} +// Tools: github:createIssue, jira:createTicket, slack:postMessage, etc. +``` + +## Adapter vs Plugin + +| Aspect | Adapter | Plugin | +| ----------- | ------------------------------------ | ----------------------------------- | +| Purpose | Generate tools from external sources | Add cross-cutting behavior | +| Output | Tools, resources, prompts | Lifecycle hooks, context extensions | +| Examples | OpenAPI → MCP tools | Caching, auth, logging | +| When to use | Integrating APIs | Adding middleware | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------- | +| Adapter registration | `OpenApiAdapter.init({ name: 'petstore', url: '...' })` in `adapters` array | Placing adapter in `plugins` array | Adapters go in `adapters`, not `plugins`; they serve different purposes | +| Tool naming | Tools auto-named as `petstore:operationId` using adapter `name` as namespace | Expecting flat names like `listPets` | Adapter name is prepended to prevent collisions across multiple adapters | +| Auth configuration | `staticAuth: { jwt: process.env.API_TOKEN! }` or `additionalHeaders` | Hardcoding secrets: `staticAuth: { jwt: 'sk-xxx' }` | Always use environment variables for secrets; never commit tokens | +| Spec source | Use `url` for hosted specs or `spec` for inline definitions | Using both `url` and `spec` simultaneously | Only one source should be provided; `spec` takes precedence and `url` is ignored | +| Multiple APIs | Register separate `OpenApiAdapter.init()` calls with unique `name` values | Using the same `name` for different adapters | Duplicate names cause tool naming collisions | + +## Verification Checklist + +### Configuration + +- [ ] `@frontmcp/adapters` package is installed +- [ ] `OpenApiAdapter.init()` is in the `adapters` array of `@App` +- [ ] Adapter has a unique `name` for tool namespacing +- [ ] `url` points to a valid, reachable OpenAPI JSON/YAML endpoint (or `spec` is inline) + +### Runtime + +- [ ] Generated tools appear in `tools/list` with `:` naming +- [ ] Auth headers are sent correctly on API calls +- [ ] Spec polling refreshes tool definitions at the configured interval +- [ ] Invalid spec URL produces a clear startup error + +### Production + +- [ ] API tokens and secrets are loaded from environment variables +- [ ] Polling interval is appropriate for the API's update frequency +- [ ] Multiple adapter registrations use distinct names + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No tools generated from spec | Spec URL returns non-OpenAPI content or is unreachable | Verify URL returns valid OpenAPI 3.x JSON; check network access | +| Authentication errors on API calls | Wrong auth config or missing credentials | Configure `staticAuth` for fixed credentials, `securityResolver`/`authProviderMapper` for dynamic auth, or `additionalHeaders` for header-based tokens; verify env vars are set | +| Duplicate tool name error | Two adapters registered with the same `name` | Give each adapter a unique `name` (e.g., `'github'`, `'jira'`) | +| Stale tools after API update | Spec polling not configured | Add `polling: { intervalMs: 300000 }` to refresh every 5 minutes | +| TypeScript error importing adapter | Wrong import path | Import from `@frontmcp/adapters`: `import { OpenApiAdapter } from '@frontmcp/adapters'` | + +## Reference + +- [Adapter Overview Documentation](https://docs.agentfront.dev/frontmcp/adapters/overview) +- Related skills: `create-adapter`, `create-plugin`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-development/references/official-plugins.md b/libs/skills/catalog/frontmcp-development/references/official-plugins.md new file mode 100644 index 00000000..bb0daedf --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/official-plugins.md @@ -0,0 +1,713 @@ +# Official FrontMCP Plugins + +FrontMCP ships 6 official plugins that extend server behavior with cross-cutting concerns: semantic tool discovery, session memory, authorization workflows, result caching, feature gating, and visual monitoring. Install individually or via `@frontmcp/plugins` (meta-package re-exporting cache, codecall, dashboard, and remember). + +## When to Use This Skill + +### Must Use + +- Installing and configuring any official FrontMCP plugin (CodeCall, Remember, Approval, Cache, Feature Flags, Dashboard) +- Adding session memory, tool caching, or authorization workflows to an existing server +- Integrating feature flag services (LaunchDarkly, Split.io, Unleash) to gate tools at runtime + +### Recommended + +- Setting up the Dashboard plugin for visual monitoring of server structure in development +- Configuring CodeCall for semantic tool discovery when the server has many tools +- Combining multiple official plugins in a production deployment + +### Skip When + +- You need to build a custom plugin with your own providers and context extensions (see `create-plugin`) +- You only need lifecycle hooks without installing an official plugin (see `create-plugin-hooks`) +- You need to generate tools from an OpenAPI spec (see `official-adapters`) + +> **Decision:** Use this skill when you need to install, configure, or customize one or more of the 6 official FrontMCP plugins. + +All plugins follow the `DynamicPlugin` pattern and are registered via `@FrontMcp({ plugins: [...] })`. + +```typescript +import { FrontMcp } from '@frontmcp/sdk'; +import CodeCallPlugin from '@frontmcp/plugin-codecall'; +import RememberPlugin from '@frontmcp/plugin-remember'; +import { ApprovalPlugin } from '@frontmcp/plugin-approval'; +import CachePlugin from '@frontmcp/plugin-cache'; +import FeatureFlagPlugin from '@frontmcp/plugin-feature-flags'; +import DashboardPlugin from '@frontmcp/plugin-dashboard'; + +@App({ name: 'MyApp' }) +class MyApp {} + +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + plugins: [ + CodeCallPlugin.init({ mode: 'codecall_only', vm: { preset: 'secure' } }), + RememberPlugin.init({ type: 'memory' }), + ApprovalPlugin.init({ mode: 'recheck' }), + CachePlugin.init({ type: 'memory', defaultTTL: 86400 }), + FeatureFlagPlugin.init({ adapter: 'static', flags: { 'new-tool': true } }), + DashboardPlugin.init({ enabled: true }), + ], + tools: [ + /* your tools */ + ], +}) +class MyServer {} +``` + +--- + +## 1. CodeCall Plugin (`@frontmcp/plugin-codecall`) + +Meta-tools for semantic search and sandboxed VM execution of tools. The AI discovers, describes, and orchestrates your tools via AgentScript instead of calling them individually. + +### Installation + +```typescript +import CodeCallPlugin from '@frontmcp/plugin-codecall'; + +@FrontMcp({ + plugins: [ + CodeCallPlugin.init({ + mode: 'codecall_only', // 'codecall_only' | 'codecall_opt_in' | 'metadata_driven' + topK: 8, // Number of search results returned + maxDefinitions: 8, // Max tool definitions per describe call + vm: { + preset: 'secure', // 'locked_down' | 'secure' | 'balanced' | 'experimental' + timeoutMs: 5000, + allowLoops: false, + }, + embedding: { + strategy: 'tfidf', // 'tfidf' | 'ml' + synonymExpansion: { enabled: true }, + }, + }), + ], +}) +class MyServer {} +``` + +### Modes + +- `codecall_only` -- Hides all tools from `list_tools` except CodeCall meta-tools. All other tools are discovered only via `codecall:search`. Best when the server has a large number of tools and you want the AI to search-then-execute. +- `codecall_opt_in` -- Shows all tools in `list_tools` normally. Tools opt-in to CodeCall execution via metadata. Useful when only some tools benefit from orchestrated execution. +- `metadata_driven` -- Per-tool `metadata.codecall` controls visibility and CodeCall availability independently. Most granular control. + +### VM Presets + +The sandboxed VM runs AgentScript (a restricted JavaScript subset). Presets control security boundaries: + +- `locked_down` -- Most restrictive. No loops, no console, minimal builtins. Suitable for untrusted environments. +- `secure` -- Default. Reasonable limits for production use. Loops disabled, console available. +- `balanced` -- Relaxed constraints for development. Loops allowed with iteration limits. +- `experimental` -- Minimal restrictions. Full loop support, extended builtins. Development only. + +### Meta-Tools Exposed + +CodeCall contributes 4 tools to your server: + +- `codecall:search` -- Semantic search over all registered tools using TF-IDF scoring with synonym expansion. Returns ranked tool names, descriptions, and relevance scores. +- `codecall:describe` -- Returns full input/output JSON schemas for one or more tools. Use after search to understand tool interfaces before execution. +- `codecall:execute` -- Runs an AgentScript program in the sandboxed VM. The script can call multiple tools, branch on results, and compose outputs. +- `codecall:invoke` -- Direct single-tool invocation (available when `directCalls` is enabled). Bypasses the VM for simple one-shot calls. + +### Per-Tool CodeCall Metadata + +Control how individual tools interact with CodeCall: + +```typescript +@Tool({ + name: 'my_tool', + codecall: { + visibleInListTools: false, // Hide from list_tools (only discoverable via codecall:search) + enabledInCodeCall: true, // Available for execution via codecall:execute + tags: ['data', 'query'], // Extra indexing hints for semantic search + }, +}) +class MyTool extends ToolContext { + /* ... */ +} +``` + +### Power Features + +- **TF-IDF Search** -- Term frequency-inverse document frequency scoring indexes tool names, descriptions, and tags. No external embedding service required. +- **Synonym Expansion** -- Automatically expands search queries with synonyms (e.g., "delete" also matches "remove", "erase"). Enable via `embedding.synonymExpansion.enabled`. +- **Pass-by-Reference via Sidecar** -- Large results are stored in a sidecar map and passed by reference between tool calls in AgentScript, avoiding serialization overhead. + +--- + +## 2. Remember Plugin (`@frontmcp/plugin-remember`) + +Encrypted session memory with multi-scope persistence. Tools can remember values across invocations and sessions using a human-friendly API. + +### Installation + +```typescript +import RememberPlugin from '@frontmcp/plugin-remember'; + +// In-memory (development) +@FrontMcp({ + plugins: [RememberPlugin.init({ type: 'memory' })], +}) +class DevServer {} + +// Redis (production) +@FrontMcp({ + plugins: [ + RememberPlugin.init({ + type: 'redis', + config: { host: 'localhost', port: 6379 }, + keyPrefix: 'remember:', + encryption: { enabled: true }, + tools: { enabled: true }, // Expose LLM tools + }), + ], +}) +class ProdServer {} + +// Redis client (bring your own ioredis instance) +@FrontMcp({ + plugins: [ + RememberPlugin.init({ + type: 'redis-client', + client: existingRedisClient, + }), + ], +}) +class ClientServer {} + +// Vercel KV +@FrontMcp({ + plugins: [RememberPlugin.init({ type: 'vercel-kv' })], +}) +class VercelServer {} + +// Global store (uses @FrontMcp redis config) +@FrontMcp({ + redis: { host: 'localhost', port: 6379 }, + plugins: [RememberPlugin.init({ type: 'global-store' })], +}) +class GlobalStoreServer {} +``` + +### Storage Types + +- `memory` -- In-process Map. Fastest, no persistence. Good for development. +- `redis` -- Dedicated Redis connection. Plugin manages the client lifecycle. +- `redis-client` -- Bring your own ioredis client instance. +- `vercel-kv` -- Vercel KV (Redis-compatible). Uses `@vercel/kv` package. +- `global-store` -- Reuses the Redis connection from `@FrontMcp({ redis: {...} })`. + +### Using `this.remember` in Tools + +```typescript +@Tool({ name: 'my_tool' }) +class MyTool extends ToolContext { + async execute(input: { query: string }) { + // Store values (default scope: 'session') + await this.remember.set('theme', 'dark'); + await this.remember.set('language', 'en', { scope: 'user' }); + await this.remember.set('temp_token', 'xyz', { ttl: 300 }); + + // Retrieve values + const theme = await this.remember.get('theme', { defaultValue: 'light' }); + + // Check existence + if (await this.remember.knows('onboarding_complete')) { + // Skip onboarding + } + + // Remove values + await this.remember.forget('temp_token'); + + // List keys matching pattern + const keys = await this.remember.list({ pattern: 'user:*' }); + + return { content: [{ type: 'text', text: `Theme: ${theme}` }] }; + } +} +``` + +### Memory Scopes + +- `session` -- Valid only for the current session. Default scope. Cleared when the session ends. +- `user` -- Persists for the user across sessions. Tied to user identity. +- `tool` -- Scoped to a specific tool + session combination. Isolated per tool. +- `global` -- Shared across all sessions and users. Use carefully. + +### Tools Exposed (when `tools.enabled: true`) + +- `remember_this` -- Store a key-value pair in memory +- `recall` -- Retrieve a previously stored value by key +- `forget` -- Remove a stored value by key +- `list_memories` -- List all stored keys, optionally filtered by pattern + +--- + +## 3. Approval Plugin (`@frontmcp/plugin-approval`) + +Tool authorization workflow with PKCE webhook security. Require explicit user or system approval before sensitive tools execute. + +### Installation + +```typescript +import { ApprovalPlugin } from '@frontmcp/plugin-approval'; + +// Recheck mode (default) -- re-evaluates approval on each call +@FrontMcp({ + plugins: [ApprovalPlugin.init()], +}) +class BasicServer {} + +// Recheck mode with explicit config +@FrontMcp({ + plugins: [ + ApprovalPlugin.init({ + mode: 'recheck', + enableAudit: true, + }), + ], +}) +class AuditedServer {} + +// Webhook mode -- PKCE-secured external approval flow +@FrontMcp({ + plugins: [ + ApprovalPlugin.init({ + mode: 'webhook', + webhook: { + url: 'https://approval.example.com/webhook', + challengeTtl: 300, + callbackPath: '/approval/callback', + }, + enableAudit: true, + maxDelegationDepth: 3, + }), + ], +}) +class WebhookServer {} +``` + +### Modes + +- `recheck` -- Re-evaluates approval status on every tool call. Approval can be granted programmatically via `this.approval.grantSessionApproval()`. Good for interactive approval flows where the user confirms in-band. +- `webhook` -- Sends a PKCE-secured webhook to an external approval service. The external service calls back to confirm or deny. Suitable for compliance workflows requiring out-of-band approval. + +### Using `this.approval` in Tools + +```typescript +@Tool({ name: 'dangerous_action' }) +class DangerousActionTool extends ToolContext { + async execute(input: { target: string }) { + // Check if tool is currently approved + const isApproved = await this.approval.isApproved('dangerous_action'); + + if (!isApproved) { + // Grant session-scoped approval programmatically + await this.approval.grantSessionApproval('dangerous_action', { + reason: 'User confirmed via prompt', + }); + } + + // Additional approval API methods: + // await this.approval.getApproval('tool-id') -- Get approval record + // await this.approval.getSessionApprovals() -- List session approvals + // await this.approval.getUserApprovals() -- List user approvals + // await this.approval.grantUserApproval('tool-id') -- Persist across sessions + // await this.approval.grantTimeLimitedApproval('tool-id', 60000) -- Auto-expire + // await this.approval.revokeApproval('tool-id') -- Revoke any approval + + return { content: [{ type: 'text', text: 'Action completed' }] }; + } +} +``` + +### Per-Tool Approval Metadata + +```typescript +@Tool({ + name: 'file_write', + approval: { + required: true, + defaultScope: 'session', // 'session' | 'user' | 'time-limited' + category: 'write', + riskLevel: 'medium', // 'low' | 'medium' | 'high' | 'critical' + approvalMessage: 'Allow file writing for this session?', + }, +}) +class FileWriteTool extends ToolContext { + /* ... */ +} +``` + +When `approval.required` is `true`, the plugin automatically intercepts tool execution and checks approval status before allowing the tool to run. + +--- + +## 4. Cache Plugin (`@frontmcp/plugin-cache`) + +Automatic tool result caching. Cache responses by tool name patterns or per-tool metadata. Supports sliding window TTL and cache bypass headers. + +### Installation + +```typescript +import CachePlugin from '@frontmcp/plugin-cache'; + +// In-memory cache +@FrontMcp({ + plugins: [ + CachePlugin.init({ + type: 'memory', + defaultTTL: 3600, // 1 hour in seconds + toolPatterns: ['api:get-*', 'search:*'], // Cache tools matching glob patterns + bypassHeader: 'x-frontmcp-disable-cache', // Header to skip cache + }), + ], +}) +class CachedServer {} + +// Redis cache +@FrontMcp({ + plugins: [ + CachePlugin.init({ + type: 'redis', + config: { host: 'localhost', port: 6379 }, + defaultTTL: 86400, // 1 day in seconds + }), + ], +}) +class RedisCachedServer {} + +// Global store (uses @FrontMcp redis config) +@FrontMcp({ + redis: { host: 'localhost', port: 6379 }, + plugins: [CachePlugin.init({ type: 'global-store' })], +}) +class GlobalCacheServer {} +``` + +### Storage Types + +- `memory` -- In-process Map with automatic eviction. No external dependencies. +- `redis` -- Dedicated Redis connection with native TTL support. Plugin manages the client. +- `redis-client` -- Bring your own ioredis client instance. +- `global-store` -- Reuses the Redis connection from `@FrontMcp({ redis: {...} })`. + +### Per-Tool Cache Metadata + +Enable caching on individual tools via the `cache` metadata field: + +```typescript +// Enable caching with default TTL +@Tool({ name: 'get_weather', cache: true }) +class GetWeatherTool extends ToolContext { + /* ... */ +} + +// Custom TTL and sliding window +@Tool({ + name: 'get_user_profile', + cache: { + ttl: 3600, // Override default TTL (seconds) + slideWindow: true, // Refresh TTL on cache hit + }, +}) +class GetUserProfileTool extends ToolContext { + /* ... */ +} +``` + +### Tool Patterns + +Use glob patterns to cache groups of tools without modifying each tool: + +```typescript +CachePlugin.init({ + type: 'memory', + defaultTTL: 3600, + toolPatterns: [ + 'namespace:*', // All tools in a namespace + 'api:get-*', // All GET-like API tools + 'search:*', // All search tools + ], +}); +``` + +A tool is cached if it matches any pattern OR has `cache: true` (or a cache object) in its metadata. + +### Cache Bypass + +Send the bypass header to skip caching for a specific request: + +```text +x-frontmcp-disable-cache: true +``` + +The header name is configurable via `bypassHeader` in the plugin options. Default: `'x-frontmcp-disable-cache'`. + +### Cache Key + +The cache key is computed from the tool name and the serialized input arguments. Two calls with identical tool name and arguments return the same cached result. + +--- + +## 5. Feature Flags Plugin (`@frontmcp/plugin-feature-flags`) + +Gate tools, resources, prompts, and skills behind feature flags. Integrates with popular feature flag services or static configuration. + +### Installation + +```typescript +import FeatureFlagPlugin from '@frontmcp/plugin-feature-flags'; + +// Static flags (no external dependency) +@FrontMcp({ + plugins: [ + FeatureFlagPlugin.init({ + adapter: 'static', + flags: { + 'beta-tools': true, + 'experimental-agent': false, + 'new-search': true, + }, + }), + ], +}) +class StaticFlagServer {} + +// Split.io +@FrontMcp({ + plugins: [ + FeatureFlagPlugin.init({ + adapter: 'splitio', + config: { apiKey: 'sdk-key-xxx' }, + }), + ], +}) +class SplitServer {} + +// LaunchDarkly +@FrontMcp({ + plugins: [ + FeatureFlagPlugin.init({ + adapter: 'launchdarkly', + config: { sdkKey: 'sdk-xxx' }, + }), + ], +}) +class LDServer {} + +// Unleash +@FrontMcp({ + plugins: [ + FeatureFlagPlugin.init({ + adapter: 'unleash', + config: { + url: 'https://unleash.example.com/api', + appName: 'my-mcp-server', + apiKey: 'xxx', + }, + }), + ], +}) +class UnleashServer {} + +// Custom adapter +@FrontMcp({ + plugins: [ + FeatureFlagPlugin.init({ + adapter: 'custom', + adapterInstance: myCustomAdapter, + }), + ], +}) +class CustomFlagServer {} +``` + +### Adapters + +- `static` -- Hardcoded flag map. No external service. Good for development and testing. +- `splitio` -- Split.io integration. Requires `@splitsoftware/splitio` package. +- `launchdarkly` -- LaunchDarkly integration. Requires `launchdarkly-node-server-sdk` package. +- `unleash` -- Unleash integration. Requires `unleash-client` package. +- `custom` -- Provide your own adapter instance implementing the `FeatureFlagAdapter` interface. + +### Using `this.featureFlags` in Tools + +```typescript +@Tool({ name: 'beta_feature' }) +class BetaFeatureTool extends ToolContext { + async execute(input: unknown) { + // Check if a flag is enabled (returns boolean) + const enabled = await this.featureFlags.isEnabled('beta-feature-flag'); + if (!enabled) { + return { content: [{ type: 'text', text: 'Feature not available' }] }; + } + + // Get variant value (for multivariate flags) + const variant = await this.featureFlags.getVariant('experiment-flag'); + // variant may be 'control', 'treatment-a', 'treatment-b', etc. + + return { content: [{ type: 'text', text: `Running variant: ${variant}` }] }; + } +} +``` + +### Per-Tool Feature Flag Gating + +Tools gated by a feature flag are automatically hidden from `list_tools` and blocked from execution when the flag is off: + +```typescript +// Simple string key -- flag must be truthy to enable the tool +@Tool({ name: 'beta_tool', featureFlag: 'enable-beta-tools' }) +class BetaTool extends ToolContext { + /* ... */ +} + +// Object with default value -- if flag evaluation fails, use the default +@Tool({ + name: 'experimental_tool', + featureFlag: { key: 'experimental-flag', defaultValue: false }, +}) +class ExperimentalTool extends ToolContext { + /* ... */ +} +``` + +The plugin hooks into listing and execution flows for tools, resources, prompts, and skills. When a flag evaluates to `false`, the corresponding entry is filtered from list results and direct invocation returns an error. + +--- + +## 6. Dashboard Plugin (`@frontmcp/plugin-dashboard`) + +Visual monitoring web UI for your FrontMCP server. View server structure (tools, resources, prompts, apps, plugins) as an interactive graph. + +### Installation + +```typescript +import DashboardPlugin from '@frontmcp/plugin-dashboard'; + +// Basic (auto-enabled in dev, disabled in production) +@FrontMcp({ + plugins: [DashboardPlugin.init({})], +}) +class DevServer {} + +// With authentication and custom CDN +@FrontMcp({ + plugins: [ + DashboardPlugin.init({ + enabled: true, + basePath: '/dashboard', + auth: { + enabled: true, + token: 'my-secret-token', + }, + cdn: { + entrypoint: 'https://cdn.example.com/dashboard-ui@1.0.0/index.js', + }, + }), + ], +}) +class ProdServer {} +// Access: http://localhost:3000/dashboard?token=my-secret-token +``` + +### Options + +```typescript +interface DashboardPluginOptionsInput { + enabled?: boolean; // Auto: enabled in dev, disabled in prod + basePath?: string; // Default: '/dashboard' + auth?: { + enabled?: boolean; // Default: false + token?: string; // Query param auth (?token=xxx) + }; + cdn?: { + entrypoint?: string; // Custom UI bundle URL + react?: string; // React CDN URL override + reactDom?: string; // React DOM CDN URL override + xyflow?: string; // XYFlow (React Flow) CDN URL override + dagre?: string; // Dagre layout CDN URL override + }; +} +``` + +- `enabled` -- When omitted, the dashboard is automatically enabled in development (`NODE_ENV !== 'production'`) and disabled in production. +- `basePath` -- URL path where the dashboard is served. Default: `'/dashboard'`. +- `auth.token` -- When set, the dashboard requires `?token=` as a query parameter. +- `cdn` -- Override default CDN URLs for the dashboard UI bundle and its dependencies. Useful for air-gapped environments. + +--- + +## Registration Pattern + +All official plugins use the static `init()` pattern inherited from `DynamicPlugin`. Register them in the `plugins` array of your `@FrontMcp` decorator: + +```typescript +@FrontMcp({ + info: { name: 'production-server', version: '1.0.0' }, + apps: [MyApp], + plugins: [ + CodeCallPlugin.init({ mode: 'codecall_only', vm: { preset: 'secure' } }), + RememberPlugin.init({ type: 'redis', config: { host: 'redis.internal' } }), + ApprovalPlugin.init({ mode: 'recheck' }), + CachePlugin.init({ type: 'redis', config: { host: 'redis.internal' }, defaultTTL: 86400 }), + FeatureFlagPlugin.init({ adapter: 'launchdarkly', config: { sdkKey: 'sdk-xxx' } }), + DashboardPlugin.init({ enabled: true, auth: { enabled: true, token: process.env.DASH_TOKEN } }), + ], + tools: [ + /* ... */ + ], +}) +class ProductionServer {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Plugin registration | `plugins: [RememberPlugin.init({ type: 'memory' })]` | `plugins: [new RememberPlugin({ type: 'memory' })]` | Official plugins use `DynamicPlugin.init()` static method; direct instantiation bypasses provider wiring | +| Remember storage in prod | `RememberPlugin.init({ type: 'redis', config: { host: '...' } })` | `RememberPlugin.init({ type: 'memory' })` in production | Memory storage loses data on restart; use Redis or Vercel KV for persistence | +| Cache TTL units | `defaultTTL: 3600` (seconds) | `defaultTTL: 3600000` (milliseconds) | Cache TTL is in seconds, not milliseconds; 3600000 = 41 days | +| Feature flag gating | `@Tool({ featureFlag: 'my-flag' })` on the tool decorator | Checking `this.featureFlags.isEnabled()` inside `execute()` and returning early | Decorator-level gating hides the tool from `list_tools`; manual check still exposes it | +| Dashboard in production | `DashboardPlugin.init({ enabled: true, auth: { enabled: true, token: '...' } })` | `DashboardPlugin.init({})` without auth in production | Dashboard auto-disables in production; if enabled, always set auth token | + +## Verification Checklist + +### Installation + +- [ ] Plugin package is installed (`@frontmcp/plugin-codecall`, `@frontmcp/plugin-remember`, etc.) +- [ ] Plugin is registered via `.init()` in the `plugins` array of `@FrontMcp` +- [ ] Required configuration options are provided (storage type, API keys, endpoints) + +### Runtime + +- [ ] `this.remember` / `this.approval` / `this.featureFlags` resolves in tool context +- [ ] Cache plugin returns cached results on repeated identical calls +- [ ] Feature-flagged tools are hidden from `list_tools` when flag is off +- [ ] Dashboard is accessible at configured `basePath` (default: `/dashboard`) +- [ ] Approval plugin blocks unapproved tools and grants approval correctly + +### Production + +- [ ] Redis or external storage is configured for Remember and Cache plugins +- [ ] Dashboard authentication is enabled with a secret token +- [ ] Feature flag adapter connects to external service (not `'static'`) + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `this.remember` is undefined | RememberPlugin not registered or missing `.init()` | Add `RememberPlugin.init({ type: 'memory' })` to `plugins` array | +| Cache not working for a tool | Tool name does not match any `toolPatterns` glob and `cache` metadata is not set | Add `cache: true` to `@Tool` decorator or add matching pattern to `toolPatterns` | +| Feature flag always returns false | Using `'static'` adapter with flag not in the `flags` map | Add the flag key to `flags: { 'my-flag': true }` or check adapter connection | +| Dashboard returns 404 | Plugin auto-disabled in production (`NODE_ENV=production`) | Set `enabled: true` explicitly in `DashboardPlugin.init()` options | +| Approval webhook times out | Callback URL not reachable from the external approval service | Verify `callbackPath` is publicly accessible and matches the webhook configuration | + +## Reference + +- [Plugins Overview Documentation](https://docs.agentfront.dev/frontmcp/plugins/overview) +- Related skills: `create-plugin`, `create-plugin-hooks`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-guides/SKILL.md b/libs/skills/catalog/frontmcp-guides/SKILL.md new file mode 100644 index 00000000..7b53fd2f --- /dev/null +++ b/libs/skills/catalog/frontmcp-guides/SKILL.md @@ -0,0 +1,417 @@ +--- +name: frontmcp-guides +description: 'End-to-end examples and best practices for building FrontMCP MCP servers. Use when starting a new project from scratch, learning architectural patterns, or following a complete build walkthrough.' +tags: [guides, examples, best-practices, architecture, walkthrough, end-to-end] +priority: 10 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/guides/overview +examples: + - scenario: Build a simple weather API MCP server from scratch + expected-outcome: Working server with tools, resources, and tests deployed to Node + - scenario: Build a task manager with auth, Redis, and multi-tool patterns + expected-outcome: Authenticated server with CRUD tools, session storage, and E2E tests + - scenario: Build a multi-app knowledge base with agents and plugins + expected-outcome: Composed server with multiple apps, AI agents, caching, and Vercel deployment +--- + +# FrontMCP End-to-End Guides + +Complete build walkthroughs and best practices for FrontMCP servers. Each example starts from an empty directory and ends with a deployed, tested server. Every pattern references the specific skill that teaches it. + +## When to Use This Skill + +### Must Use + +- Starting a new FrontMCP project from scratch and want a complete walkthrough to follow +- Learning FrontMCP architecture by building progressively complex real examples +- Need to see how multiple skills work together in a complete application + +### Recommended + +- Planning a new project and want to see how similar architectures are structured +- Onboarding a team member who learns best from complete working examples +- Reviewing best practices for file organization, naming, and code patterns + +### Skip When + +- You need to learn one specific component type (use the specific skill, e.g., `create-tool`) +- Looking for the right skill for a task (use domain routers: `frontmcp-development`, `frontmcp-deployment`, etc.) +- You need CLI/install instructions for the skills system (see `frontmcp-skills-usage`) + +> **Decision:** Use this skill when you want to see how everything fits together. Use individual skills when you need focused instruction. + +## Prerequisites + +- Node.js 22+ and npm/yarn installed +- Familiarity with TypeScript and decorators +- `frontmcp` CLI available globally (`npm install -g frontmcp`) + +## Steps + +1. Choose an example that matches your project's complexity level (Beginner, Intermediate, Advanced) +2. Work through the Planning Checklist to define your project's scope +3. Follow the example code and architecture, referencing individual skills for deeper guidance +4. Verify your implementation using the Verification Checklist at the end of this skill + +## Planning Checklist + +Before writing any code, answer these questions: + +### 1. What does the server do? + +- What tools does it expose? (actions AI clients can call) +- What resources does it expose? (data AI clients can read) +- What prompts does it expose? (conversation templates) + +### 2. How is it organized? + +- Single app or multiple apps? (see `multi-app-composition`) +- Standalone project or Nx monorepo? (see `project-structure-standalone`, `project-structure-nx`) + +### 3. How is it secured? + +- Public (no auth), transparent (passthrough), local (self-contained), or remote (OAuth)? (see `configure-auth`) +- What session storage? Memory (dev), Redis (prod), Vercel KV (serverless)? (see `configure-session`) + +### 4. Where does it deploy? + +- Node, Vercel, Lambda, Cloudflare, CLI, browser, or SDK? (see `frontmcp-deployment`) +- What transport? stdio (local), SSE (streaming), Streamable HTTP (stateless)? (see `configure-transport`) + +### 5. How is it tested? + +- Unit tests for each component (see `setup-testing`) +- E2E tests for protocol-level flows +- Coverage target: 95%+ + +--- + +## Example 1: Weather API (Beginner) + +**Skills used:** `setup-project`, `create-tool`, `create-resource`, `setup-testing`, `deploy-to-node` + +A simple MCP server that exposes a weather lookup tool and a resource for supported cities. + +### Architecture + +```text +weather-api/ +├── src/ +│ ├── main.ts # @FrontMcp server (deploy-to-node) +│ ├── weather.app.ts # @App with tools and resources +│ ├── tools/ +│ │ └── get-weather.tool.ts # @Tool: fetch weather by city (create-tool) +│ └── resources/ +│ └── cities.resource.ts # @Resource: list supported cities (create-resource) +├── test/ +│ ├── get-weather.tool.spec.ts # Unit tests (setup-testing) +│ └── weather.e2e.spec.ts # E2E protocol test (setup-testing) +└── package.json +``` + +### Key Code + +**Server entry point** (`setup-project`): + +```typescript +import { FrontMcp } from '@frontmcp/sdk'; +import { WeatherApp } from './weather.app'; + +@FrontMcp({ + info: { name: 'weather-api', version: '1.0.0' }, + apps: [WeatherApp], +}) +export default class WeatherServer {} +``` + +**Tool** (`create-tool`): + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'get_weather', + description: 'Get current weather for a city', + inputSchema: { + city: z.string().min(1).describe('City name'), + }, + outputSchema: { + temperature: z.number(), + condition: z.string(), + humidity: z.number(), + }, +}) +export class GetWeatherTool extends ToolContext { + async execute(input: { city: string }) { + const city = encodeURIComponent(input.city); + const data = await this.fetch(`https://api.weather.example.com/v1?city=${city}`); + const json = await data.json(); + return { temperature: json.temp, condition: json.condition, humidity: json.humidity }; + } +} +``` + +**Resource** (`create-resource`): + +```typescript +import { Resource, ResourceContext } from '@frontmcp/sdk'; + +@Resource({ + uri: 'weather://cities', + name: 'Supported Cities', + description: 'List of cities with weather data', + mimeType: 'application/json', +}) +export class CitiesResource extends ResourceContext { + async read() { + return JSON.stringify(['London', 'Tokyo', 'New York', 'Paris', 'Sydney']); + } +} +``` + +> **Full working code:** See `references/example-weather-api.md` + +--- + +## Example 2: Task Manager (Intermediate) + +**Skills used:** `setup-project`, `create-tool`, `create-provider`, `configure-auth`, `configure-session`, `setup-redis`, `setup-testing`, `deploy-to-vercel` + +An authenticated task management server with CRUD tools, Redis storage, and OAuth. + +### Architecture + +```text +task-manager/ +├── src/ +│ ├── main.ts # @FrontMcp with auth: { mode: 'remote' } +│ ├── tasks.app.ts # @App with CRUD tools + provider +│ ├── providers/ +│ │ └── task-store.provider.ts # @Provider: Redis-backed task storage (create-provider) +│ ├── tools/ +│ │ ├── create-task.tool.ts # @Tool: create a task (create-tool) +│ │ ├── list-tasks.tool.ts # @Tool: list tasks (create-tool) +│ │ ├── update-task.tool.ts # @Tool: update task status (create-tool) +│ │ └── delete-task.tool.ts # @Tool: delete a task (create-tool) +│ └── types/ +│ └── task.ts # Shared task interface +├── test/ +│ ├── *.spec.ts # Unit tests per tool +│ └── tasks.e2e.spec.ts # E2E with auth flow +├── vercel.json # Vercel config (deploy-to-vercel) +└── package.json +``` + +### Key Code + +**Server with auth** (`configure-auth`, `configure-session`, `setup-redis`): + +```typescript +@FrontMcp({ + info: { name: 'task-manager', version: '1.0.0' }, + apps: [TasksApp], + auth: { mode: 'remote', provider: 'https://auth.example.com', clientId: 'my-client-id' }, + redis: { provider: 'redis', host: process.env.REDIS_URL ?? 'localhost' }, +}) +export default class TaskManagerServer {} +``` + +**Provider for shared storage** (`create-provider`): + +```typescript +import { Provider } from '@frontmcp/sdk'; +import type { Token } from '@frontmcp/di'; + +export interface TaskStore { + create(task: Task): Promise; + list(userId: string): Promise; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; +} + +export const TASK_STORE: Token = Symbol('TaskStore'); + +@Provider({ token: TASK_STORE }) +export class RedisTaskStoreProvider implements TaskStore { + // Redis-backed implementation +} +``` + +**Tool with DI** (`create-tool` + `create-provider`): + +```typescript +@Tool({ + name: 'create_task', + description: 'Create a new task', + inputSchema: { + title: z.string().min(1).describe('Task title'), + priority: z.enum(['low', 'medium', 'high']).default('medium'), + }, + outputSchema: { id: z.string(), title: z.string(), priority: z.string(), status: z.string() }, +}) +export class CreateTaskTool extends ToolContext { + async execute(input: { title: string; priority: string }) { + const store = this.get(TASK_STORE); + return store.create({ title: input.title, priority: input.priority, status: 'pending' }); + } +} +``` + +> **Full working code:** See `references/example-task-manager.md` + +--- + +## Example 3: Knowledge Base (Advanced) + +**Skills used:** `setup-project`, `multi-app-composition`, `create-tool`, `create-resource`, `create-agent`, `create-skill-with-tools`, `create-plugin`, `official-plugins`, `configure-auth`, `deploy-to-vercel` + +A multi-app knowledge base with AI-powered search, document ingestion, and an autonomous research agent. + +### Architecture + +```text +knowledge-base/ +├── src/ +│ ├── main.ts # @FrontMcp composing 3 apps +│ ├── ingestion/ +│ │ ├── ingestion.app.ts # @App: document ingestion +│ │ ├── tools/ingest-document.tool.ts +│ │ └── providers/vector-store.provider.ts +│ ├── search/ +│ │ ├── search.app.ts # @App: search and retrieval +│ │ ├── tools/search-docs.tool.ts +│ │ └── resources/doc.resource.ts +│ ├── research/ +│ │ ├── research.app.ts # @App: AI research agent +│ │ └── agents/researcher.agent.ts # @Agent: autonomous research loop +│ └── plugins/ +│ └── audit-log.plugin.ts # @Plugin: audit logging +├── test/ +│ └── *.spec.ts +├── vercel.json +└── package.json +``` + +### Key Code + +**Multi-app composition** (`multi-app-composition`): + +```typescript +@FrontMcp({ + info: { name: 'knowledge-base', version: '1.0.0' }, + apps: [IngestionApp, SearchApp, ResearchApp], + plugins: [AuditLogPlugin], + auth: { mode: 'remote', provider: 'https://auth.example.com', clientId: 'my-client-id' }, + redis: { provider: 'redis', host: process.env.REDIS_URL ?? 'localhost' }, +}) +export default class KnowledgeBaseServer {} +``` + +**AI Research Agent** (`create-agent`): + +```typescript +@Agent({ + name: 'research_topic', + description: 'Research a topic across the knowledge base and synthesize findings', + inputSchema: { + topic: z.string().describe('Research topic'), + depth: z.enum(['shallow', 'deep']).default('shallow'), + }, + llm: { + provider: 'anthropic', + model: 'claude-sonnet-4-5', + apiKey: { env: 'ANTHROPIC_API_KEY' }, + maxTokens: 4096, + }, // provider and model are client-configurable + tools: [SearchDocsTool, IngestDocumentTool], +}) +export class ResearcherAgent extends AgentContext { + async execute(input: { topic: string; depth: string }) { + return this.run( + `Research "${input.topic}" at ${input.depth} depth. Search for relevant documents, synthesize findings, and provide a structured summary.`, + ); + } +} +``` + +> **Full working code:** See `references/example-knowledge-base.md` + +--- + +## Best Practices + +### Planning + +| Practice | Why | Skill Reference | +| ------------------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------- | +| Start with the `@App` boundaries, not individual tools | Apps define module boundaries; tools are implementation details | `multi-app-composition` | +| Choose auth mode and storage before writing tools | Auth affects session handling, which affects storage requirements | `configure-auth`, `configure-session` | +| Pick your deployment target early | Target determines transport, storage, and build constraints | `frontmcp-deployment` | + +### Organizing Code + +| Practice | Why | Skill Reference | +| ------------------------------------------------- | ----------------------------------------------------------- | ------------------------------ | +| One class per file with `..ts` naming | Consistency, generator compatibility, clear imports | `project-structure-standalone` | +| Group by feature, not by type, for 10+ components | Feature folders scale better than flat `tools/` directories | `project-structure-standalone` | +| Extract shared logic into `@Provider` classes | Testable, lifecycle-managed, injected via DI | `create-provider` | + +### Writing Code + +| Practice | Why | Skill Reference | +| ----------------------------------------------- | ------------------------------------------------------------- | ----------------- | +| Always define `outputSchema` on tools | Prevents data leaks, enables CodeCall chaining | `create-tool` | +| Use `this.fail()` with MCP error classes | Proper error codes in protocol responses | `create-tool` | +| Use `this.get(TOKEN)` not `this.tryGet(TOKEN)!` | Clear error on missing dependency vs silent null | `create-provider` | +| Use Zod raw shapes, not `z.object()` | Framework wraps internally; double-wrapping breaks validation | `create-tool` | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ----------------- | ------------------------------------------- | ----------------------------------------- | --------------------------------------------------------- | +| Project start | Plan apps and auth first, then build tools | Jump straight into writing tools | Architecture decisions are expensive to change later | +| Code organization | Feature folders with `..ts` | Flat directory with generic names | Scales to large projects and matches generator output | +| Shared state | `@Provider` with DI token | Module-level singleton or global variable | DI is testable, lifecycle-managed, and scoped per request | +| Error handling | `this.fail(new ResourceNotFoundError(...))` | `throw new Error('not found')` | MCP error codes enable proper client error handling | +| Testing | Unit tests per component + E2E for protocol | Only E2E tests or only unit tests | Both layers catch different types of bugs | + +## Verification Checklist + +### Architecture + +- [ ] Apps define clear module boundaries with no circular imports +- [ ] Shared logic extracted into providers, not duplicated across tools +- [ ] Auth mode and storage chosen before writing tools + +### Code Quality + +- [ ] All tools have `outputSchema` defined +- [ ] All files follow `..ts` naming convention +- [ ] All test files use `.spec.ts` extension +- [ ] Coverage at 95%+ across all metrics + +### Production Readiness + +- [ ] Secrets stored in environment variables, not source code +- [ ] Session storage uses Redis/KV in production (not memory) +- [ ] Rate limiting configured for public-facing tools +- [ ] E2E tests exercise the full protocol flow + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ | +| Unsure where to start | No project plan | Run through the Planning Checklist above before writing code | +| Architecture feels wrong | Wrong app boundaries or component types | Review the Scenario Routing Table in `frontmcp-development` | +| Feature works locally but fails deployed | Environment-specific config (storage, auth, transport) | Check the Target Comparison in `frontmcp-deployment` | +| Tests pass but coverage below 95% | Missing error path or branch tests | Run `jest --coverage` and add tests for uncovered lines | +| Provider state leaking between requests | Using module-level state instead of DI | Move state into a `@Provider` scoped per request | + +## Reference + +- [Guides Documentation](https://docs.agentfront.dev/frontmcp/guides/overview) +- Domain routers: `frontmcp-development`, `frontmcp-deployment`, `frontmcp-testing`, `frontmcp-config` +- Core skills: `setup-project`, `create-tool`, `create-resource`, `create-provider`, `create-agent`, `configure-auth`, `setup-testing` diff --git a/libs/skills/catalog/frontmcp-guides/references/example-knowledge-base.md b/libs/skills/catalog/frontmcp-guides/references/example-knowledge-base.md new file mode 100644 index 00000000..eabf806c --- /dev/null +++ b/libs/skills/catalog/frontmcp-guides/references/example-knowledge-base.md @@ -0,0 +1,636 @@ +# Example: Knowledge Base (Advanced) + +> Skills used: setup-project, multi-app-composition, create-tool, create-resource, create-provider, create-agent, create-plugin, configure-auth, deploy-to-vercel + +A multi-app knowledge base MCP server with three composed apps: document ingestion with vector storage, semantic search with resource templates, and an autonomous AI research agent. Includes a custom audit log plugin and demonstrates advanced patterns like multi-app composition, DI across app boundaries, agent inner tools, and plugin hooks. + +--- + +## Server Entry Point + +```typescript +// src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; +import { IngestionApp } from './ingestion/ingestion.app'; +import { SearchApp } from './search/search.app'; +import { ResearchApp } from './research/research.app'; +import { AuditLogPlugin } from './plugins/audit-log.plugin'; + +@FrontMcp({ + info: { name: 'knowledge-base', version: '1.0.0' }, + apps: [IngestionApp, SearchApp, ResearchApp], + plugins: [AuditLogPlugin], + auth: { mode: 'remote', provider: 'https://auth.example.com', clientId: 'my-client-id' }, + redis: { provider: 'redis', host: process.env.REDIS_URL ?? 'localhost' }, +}) +export default class KnowledgeBaseServer {} +``` + +--- + +## Ingestion App + +### App Registration + +```typescript +// src/ingestion/ingestion.app.ts +import { App } from '@frontmcp/sdk'; +import { VectorStoreProvider } from './providers/vector-store.provider'; +import { IngestDocumentTool } from './tools/ingest-document.tool'; + +@App({ + name: 'Ingestion', + description: 'Document ingestion and chunking pipeline', + providers: [VectorStoreProvider], + tools: [IngestDocumentTool], +}) +export class IngestionApp {} +``` + +### Provider: Vector Store + +```typescript +// src/ingestion/providers/vector-store.provider.ts +import { Provider } from '@frontmcp/sdk'; +import type { Token } from '@frontmcp/di'; + +export interface DocumentChunk { + id: string; + documentId: string; + content: string; + embedding: number[]; + metadata: Record; +} + +export interface VectorStore { + upsert(chunks: DocumentChunk[]): Promise; + search(embedding: number[], topK: number): Promise; + getByDocumentId(documentId: string): Promise; + deleteByDocumentId(documentId: string): Promise; +} + +export const VECTOR_STORE: Token = Symbol('VectorStore'); + +@Provider({ token: VECTOR_STORE }) +export class VectorStoreProvider implements VectorStore { + private client!: { upsert: Function; query: Function; delete: Function }; + + async onInit(): Promise { + const apiKey = process.env.VECTOR_DB_API_KEY; + if (!apiKey) { + throw new Error('VECTOR_DB_API_KEY environment variable is required'); + } + + // Initialize your vector DB client (e.g., Pinecone, Weaviate, Qdrant) + this.client = await this.createVectorClient(apiKey); + } + + async upsert(chunks: DocumentChunk[]): Promise { + await this.client.upsert( + chunks.map((c) => ({ + id: c.id, + values: c.embedding, + metadata: { ...c.metadata, documentId: c.documentId, content: c.content }, + })), + ); + } + + async search(embedding: number[], topK: number): Promise { + const results = await this.client.query({ vector: embedding, topK }); + return results.matches.map((m: Record) => ({ + id: m.id as string, + documentId: (m.metadata as Record).documentId, + content: (m.metadata as Record).content, + embedding: m.values as number[], + metadata: m.metadata as Record, + })); + } + + async getByDocumentId(documentId: string): Promise { + const results = await this.client.query({ + filter: { documentId }, + topK: 100, + vector: new Array(1536).fill(0), + }); + return results.matches.map((m: Record) => ({ + id: m.id as string, + documentId, + content: (m.metadata as Record).content, + embedding: m.values as number[], + metadata: m.metadata as Record, + })); + } + + async deleteByDocumentId(documentId: string): Promise { + await this.client.delete({ filter: { documentId } }); + } + + private async createVectorClient(_apiKey: string): Promise<{ upsert: Function; query: Function; delete: Function }> { + // Stub: replace with your vector DB SDK (e.g., Pinecone, Weaviate, Qdrant) + // This placeholder focuses on the FrontMCP patterns, not the vector DB integration. + throw new Error('Implement with your vector DB provider (e.g., Pinecone, Weaviate, Qdrant)'); + } +} +``` + +### Tool: Ingest Document + +```typescript +// src/ingestion/tools/ingest-document.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { VECTOR_STORE } from '../providers/vector-store.provider'; +import type { DocumentChunk } from '../providers/vector-store.provider'; + +@Tool({ + name: 'ingest_document', + description: 'Ingest a document by chunking its content and storing embeddings', + inputSchema: { + documentId: z.string().min(1).describe('Unique document identifier'), + title: z.string().min(1).describe('Document title'), + content: z.string().min(1).describe('Full document text content'), + tags: z.array(z.string()).default([]).describe('Optional tags for filtering'), + }, + outputSchema: { + documentId: z.string(), + chunksCreated: z.number(), + title: z.string(), + }, +}) +export class IngestDocumentTool extends ToolContext { + async execute(input: { documentId: string; title: string; content: string; tags: string[] }) { + const store = this.get(VECTOR_STORE); + + this.mark('chunking'); + const textChunks = this.chunkText(input.content, 512); + + this.mark('embedding'); + await this.respondProgress(0, textChunks.length); + + const chunks: DocumentChunk[] = []; + for (let i = 0; i < textChunks.length; i++) { + const embedding = await this.generateEmbedding(textChunks[i]); + chunks.push({ + id: `${input.documentId}-chunk-${i}`, + documentId: input.documentId, + content: textChunks[i], + embedding, + metadata: { title: input.title, tags: input.tags.join(','), chunkIndex: String(i) }, + }); + await this.respondProgress(i + 1, textChunks.length); + } + + this.mark('storing'); + await store.upsert(chunks); + + await this.notify(`Ingested "${input.title}" with ${chunks.length} chunks`, 'info'); + + return { + documentId: input.documentId, + chunksCreated: chunks.length, + title: input.title, + }; + } + + private chunkText(text: string, maxTokens: number): string[] { + const sentences = text.split(/(?<=[.!?])\s+/); + const chunks: string[] = []; + let current = ''; + + for (const sentence of sentences) { + if ((current + ' ' + sentence).trim().length > maxTokens * 4) { + if (current) chunks.push(current.trim()); + current = sentence; + } else { + current = current ? current + ' ' + sentence : sentence; + } + } + if (current.trim()) chunks.push(current.trim()); + return chunks; + } + + private async generateEmbedding(text: string): Promise { + const response = await this.fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ input: text, model: 'text-embedding-3-small' }), + }); + const data = await response.json(); + return data.data[0].embedding; + } +} +``` + +--- + +## Search App + +### App Registration + +```typescript +// src/search/search.app.ts +import { App } from '@frontmcp/sdk'; +import { VectorStoreProvider } from '../ingestion/providers/vector-store.provider'; +import { SearchDocsTool } from './tools/search-docs.tool'; +import { DocResource } from './resources/doc.resource'; + +@App({ + name: 'Search', + description: 'Semantic search and document retrieval', + providers: [VectorStoreProvider], + tools: [SearchDocsTool], + resources: [DocResource], +}) +export class SearchApp {} +``` + +### Tool: Search Documents + +```typescript +// src/search/tools/search-docs.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { VECTOR_STORE } from '../../ingestion/providers/vector-store.provider'; + +@Tool({ + name: 'search_docs', + description: 'Semantic search across the knowledge base', + inputSchema: { + query: z.string().min(1).describe('Natural language search query'), + topK: z.number().int().min(1).max(20).default(5).describe('Number of results'), + }, + outputSchema: { + results: z.array( + z.object({ + documentId: z.string(), + content: z.string(), + score: z.number(), + title: z.string(), + }), + ), + total: z.number(), + }, +}) +export class SearchDocsTool extends ToolContext { + async execute(input: { query: string; topK: number }) { + const store = this.get(VECTOR_STORE); + + this.mark('embedding-query'); + const queryEmbedding = await this.generateQueryEmbedding(input.query); + + this.mark('searching'); + const chunks = await store.search(queryEmbedding, input.topK); + + const results = chunks.map((chunk) => ({ + documentId: chunk.documentId, + content: chunk.content, + score: chunk.metadata.score ? parseFloat(chunk.metadata.score) : 0, + title: chunk.metadata.title ?? 'Untitled', + })); + + return { results, total: results.length }; + } + + private async generateQueryEmbedding(query: string): Promise { + const response = await this.fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ input: query, model: 'text-embedding-3-small' }), + }); + const data = await response.json(); + return data.data[0].embedding; + } +} +``` + +### Resource Template: Document by ID + +```typescript +// src/search/resources/doc.resource.ts +import { ResourceTemplate, ResourceContext } from '@frontmcp/sdk'; +import type { ReadResourceResult } from '@frontmcp/protocol'; +import { VECTOR_STORE } from '../../ingestion/providers/vector-store.provider'; + +@ResourceTemplate({ + name: 'document', + uriTemplate: 'kb://documents/{documentId}', + description: 'Retrieve all chunks of a document by its ID', + mimeType: 'application/json', +}) +export class DocResource extends ResourceContext<{ documentId: string }> { + async execute(uri: string, params: { documentId: string }): Promise { + const store = this.get(VECTOR_STORE); + const chunks = await store.getByDocumentId(params.documentId); + + if (chunks.length === 0) { + this.fail(new Error(`Document not found: ${params.documentId}`)); + } + + const document = { + documentId: params.documentId, + title: chunks[0].metadata.title ?? 'Untitled', + chunks: chunks.map((c) => ({ + chunkIndex: c.metadata.chunkIndex, + content: c.content, + })), + }; + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(document, null, 2), + }, + ], + }; + } +} +``` + +--- + +## Research App + +### App Registration + +```typescript +// src/research/research.app.ts +import { App } from '@frontmcp/sdk'; +import { ResearcherAgent } from './agents/researcher.agent'; + +@App({ + name: 'Research', + description: 'AI-powered research agent for knowledge synthesis', + agents: [ResearcherAgent], +}) +export class ResearchApp {} +``` + +### Agent: Researcher + +```typescript +// src/research/agents/researcher.agent.ts +import { Agent, AgentContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { SearchDocsTool } from '../../search/tools/search-docs.tool'; +import { IngestDocumentTool } from '../../ingestion/tools/ingest-document.tool'; + +@Agent({ + name: 'research_topic', + description: 'Research a topic across the knowledge base and synthesize findings into a structured report', + inputSchema: { + topic: z.string().min(1).describe('Research topic or question'), + depth: z.enum(['shallow', 'deep']).default('shallow').describe('Research depth'), + }, + outputSchema: { + topic: z.string(), + summary: z.string(), + sources: z.array( + z.object({ + documentId: z.string(), + title: z.string(), + relevance: z.string(), + }), + ), + confidence: z.enum(['low', 'medium', 'high']), + }, + llm: { + provider: 'anthropic', // Any supported provider — 'anthropic', 'openai', etc. + model: 'claude-sonnet-4-20250514', // Any supported model for the chosen provider + apiKey: { env: 'ANTHROPIC_API_KEY' }, + maxTokens: 4096, + }, + tools: [SearchDocsTool, IngestDocumentTool], + systemInstructions: `You are a research assistant with access to a knowledge base. +Your job is to: +1. Search the knowledge base for relevant documents using the search_docs tool. +2. Analyze the results and identify key themes. +3. If depth is "deep", perform multiple searches with refined queries. +4. Synthesize findings into a structured summary with source attribution. +Always cite which documents support your findings.`, +}) +export class ResearcherAgent extends AgentContext { + async execute(input: { topic: string; depth: 'shallow' | 'deep' }) { + const maxIterations = input.depth === 'deep' ? 5 : 2; + const prompt = [ + `Research the following topic: "${input.topic}"`, + `Depth: ${input.depth} (max ${maxIterations} search iterations)`, + 'Search the knowledge base, analyze results, and produce a structured summary.', + 'Return your findings as JSON matching the output schema.', + ].join('\n'); + + return this.run(prompt, { maxIterations }); + } +} +``` + +--- + +## Plugin: Audit Log + +```typescript +// src/plugins/audit-log.plugin.ts +import { Plugin } from '@frontmcp/sdk'; +import type { PluginHookContext } from '@frontmcp/sdk'; + +@Plugin({ + name: 'AuditLog', + description: 'Logs all tool invocations for audit compliance', +}) +export class AuditLogPlugin { + private readonly logs: Array<{ + timestamp: string; + tool: string; + userId: string | undefined; + duration: number; + success: boolean; + }> = []; + + async onToolExecuteBefore(ctx: PluginHookContext): Promise { + ctx.state.set('audit:startTime', Date.now()); + } + + async onToolExecuteAfter(ctx: PluginHookContext): Promise { + const startTime = ctx.state.get('audit:startTime') as number; + const duration = Date.now() - startTime; + + const entry = { + timestamp: new Date().toISOString(), + tool: ctx.toolName, + userId: ctx.session?.userId, + duration, + success: true, + }; + this.logs.push(entry); + + // In production, send to an external logging service + if (process.env.AUDIT_LOG_ENDPOINT) { + await ctx + .fetch(process.env.AUDIT_LOG_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }) + .catch(() => { + // Audit logging should not block tool execution + }); + } + } + + async onToolExecuteError(ctx: PluginHookContext): Promise { + const startTime = ctx.state.get('audit:startTime') as number; + const duration = Date.now() - startTime; + + this.logs.push({ + timestamp: new Date().toISOString(), + tool: ctx.toolName, + userId: ctx.session?.userId, + duration, + success: false, + }); + } + + getLogs(): typeof this.logs { + return [...this.logs]; + } +} +``` + +--- + +## Test: Researcher Agent + +```typescript +// test/researcher.agent.spec.ts +import { AgentContext } from '@frontmcp/sdk'; +import { ResearcherAgent } from '../src/research/agents/researcher.agent'; + +describe('ResearcherAgent', () => { + let agent: ResearcherAgent; + + beforeEach(() => { + agent = new ResearcherAgent(); + }); + + it('should configure shallow depth with 2 max iterations', async () => { + const runFn = jest.fn().mockResolvedValue({ + topic: 'TypeScript patterns', + summary: 'Key patterns include generics and type guards.', + sources: [{ documentId: 'doc-1', title: 'TS Handbook', relevance: 'high' }], + confidence: 'medium', + }); + + const ctx = { + run: runFn, + get: jest.fn(), + tryGet: jest.fn(), + fail: jest.fn((err: Error) => { + throw err; + }), + mark: jest.fn(), + notify: jest.fn(), + respondProgress: jest.fn(), + } as unknown as AgentContext; + Object.assign(agent, ctx); + + const result = await agent.execute({ + topic: 'TypeScript patterns', + depth: 'shallow', + }); + + expect(runFn).toHaveBeenCalledWith(expect.stringContaining('TypeScript patterns'), { maxIterations: 2 }); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('sources'); + expect(result.confidence).toBe('medium'); + }); + + it('should configure deep depth with 5 max iterations', async () => { + const runFn = jest.fn().mockResolvedValue({ + topic: 'Distributed systems', + summary: 'Consensus, replication, and partition tolerance.', + sources: [], + confidence: 'low', + }); + + const ctx = { + run: runFn, + get: jest.fn(), + tryGet: jest.fn(), + fail: jest.fn((err: Error) => { + throw err; + }), + mark: jest.fn(), + notify: jest.fn(), + respondProgress: jest.fn(), + } as unknown as AgentContext; + Object.assign(agent, ctx); + + await agent.execute({ topic: 'Distributed systems', depth: 'deep' }); + + expect(runFn).toHaveBeenCalledWith(expect.stringContaining('Distributed systems'), { maxIterations: 5 }); + }); +}); +``` + +--- + +## Test: Audit Log Plugin + +```typescript +// test/audit-log.plugin.spec.ts +import { AuditLogPlugin } from '../src/plugins/audit-log.plugin'; +import type { PluginHookContext } from '@frontmcp/sdk'; + +describe('AuditLogPlugin', () => { + let plugin: AuditLogPlugin; + + beforeEach(() => { + plugin = new AuditLogPlugin(); + }); + + it('should record a successful tool execution', async () => { + const state = new Map(); + const ctx = { + toolName: 'search_docs', + session: { userId: 'user-1' }, + state: { set: (k: string, v: unknown) => state.set(k, v), get: (k: string) => state.get(k) }, + fetch: jest.fn(), + } as unknown as PluginHookContext; + + await plugin.onToolExecuteBefore(ctx); + await plugin.onToolExecuteAfter(ctx); + + const logs = plugin.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].tool).toBe('search_docs'); + expect(logs[0].success).toBe(true); + expect(logs[0].userId).toBe('user-1'); + expect(logs[0].duration).toBeGreaterThanOrEqual(0); + }); + + it('should record a failed tool execution', async () => { + const state = new Map(); + const ctx = { + toolName: 'ingest_document', + session: undefined, + state: { set: (k: string, v: unknown) => state.set(k, v), get: (k: string) => state.get(k) }, + fetch: jest.fn(), + } as unknown as PluginHookContext; + + await plugin.onToolExecuteBefore(ctx); + await plugin.onToolExecuteError(ctx); + + const logs = plugin.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].success).toBe(false); + expect(logs[0].userId).toBeUndefined(); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md b/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md new file mode 100644 index 00000000..c39fd9b9 --- /dev/null +++ b/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md @@ -0,0 +1,512 @@ +# Example: Task Manager (Intermediate) + +> Skills used: setup-project, create-tool, create-provider, configure-auth, configure-session, setup-redis, setup-testing, deploy-to-vercel + +An authenticated task management MCP server with CRUD tools, a Redis-backed provider for storage, OAuth authentication, and Vercel deployment. Demonstrates DI with tokens, session management, per-user data isolation, and authenticated E2E testing. + +--- + +## Project Setup + +```jsonc +// package.json +{ + "name": "task-manager", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "frontmcp build --target vercel", + "start": "frontmcp start", + "test": "jest --coverage", + }, + "dependencies": { + "@frontmcp/sdk": "^1.0.0", + "ioredis": "^5.4.0", + "zod": "^3.23.0", + }, + "devDependencies": { + "@frontmcp/testing": "^1.0.0", + "jest": "^29.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.4.0", + }, +} +``` + +--- + +## Server Entry Point + +```typescript +// src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; +import { TasksApp } from './tasks.app'; + +@FrontMcp({ + info: { name: 'task-manager', version: '1.0.0' }, + apps: [TasksApp], + auth: { mode: 'remote', provider: 'https://auth.example.com', clientId: 'my-client-id' }, + redis: { provider: 'redis', host: process.env.REDIS_URL ?? 'localhost' }, +}) +export default class TaskManagerServer {} +``` + +--- + +## App Registration + +```typescript +// src/tasks.app.ts +import { App } from '@frontmcp/sdk'; +import { RedisTaskStoreProvider } from './providers/task-store.provider'; +import { CreateTaskTool } from './tools/create-task.tool'; +import { ListTasksTool } from './tools/list-tasks.tool'; +import { UpdateTaskTool } from './tools/update-task.tool'; +import { DeleteTaskTool } from './tools/delete-task.tool'; + +@App({ + name: 'Tasks', + description: 'Task management with CRUD operations', + providers: [RedisTaskStoreProvider], + tools: [CreateTaskTool, ListTasksTool, UpdateTaskTool, DeleteTaskTool], +}) +export class TasksApp {} +``` + +--- + +## Shared Types + +```typescript +// src/types/task.ts +export interface Task { + id: string; + title: string; + priority: 'low' | 'medium' | 'high'; + status: 'pending' | 'in_progress' | 'done'; + userId: string; + createdAt: string; +} +``` + +--- + +## Provider: Redis Task Store + +```typescript +// src/providers/task-store.provider.ts +import { Provider } from '@frontmcp/sdk'; +import type { Token } from '@frontmcp/di'; +import type { Task } from '../types/task'; + +export interface TaskStore { + create(task: Omit): Promise; + list(userId: string): Promise; + update(id: string, userId: string, data: Partial>): Promise; + delete(id: string, userId: string): Promise; +} + +export const TASK_STORE: Token = Symbol('TaskStore'); + +@Provider({ token: TASK_STORE }) +export class RedisTaskStoreProvider implements TaskStore { + private redis!: import('ioredis').default; + + async onInit(): Promise { + const Redis = (await import('ioredis')).default; + this.redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'); + } + + async create(input: Omit): Promise { + const { randomUUID } = await import('@frontmcp/utils'); + const task: Task = { + ...input, + id: randomUUID(), + createdAt: new Date().toISOString(), + }; + await this.redis.hset(`tasks:${task.userId}`, task.id, JSON.stringify(task)); + return task; + } + + async list(userId: string): Promise { + const entries = await this.redis.hgetall(`tasks:${userId}`); + return Object.values(entries).map((v) => JSON.parse(v) as Task); + } + + async update(id: string, userId: string, data: Partial>): Promise { + const raw = await this.redis.hget(`tasks:${userId}`, id); + if (!raw) { + throw new Error(`Task not found: ${id}`); + } + const task: Task = { ...(JSON.parse(raw) as Task), ...data }; + await this.redis.hset(`tasks:${userId}`, id, JSON.stringify(task)); + return task; + } + + async delete(id: string, userId: string): Promise { + const removed = await this.redis.hdel(`tasks:${userId}`, id); + if (removed === 0) { + throw new Error(`Task not found: ${id}`); + } + } + + async onDestroy(): Promise { + await this.redis.quit(); + } +} +``` + +--- + +## Tool: Create Task + +```typescript +// src/tools/create-task.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { TASK_STORE } from '../providers/task-store.provider'; + +@Tool({ + name: 'create_task', + description: 'Create a new task for the authenticated user', + inputSchema: { + title: z.string().min(1).max(200).describe('Task title'), + priority: z.enum(['low', 'medium', 'high']).default('medium').describe('Task priority'), + }, + outputSchema: { + id: z.string(), + title: z.string(), + priority: z.string(), + status: z.string(), + createdAt: z.string(), + }, +}) +export class CreateTaskTool extends ToolContext { + async execute(input: { title: string; priority: 'low' | 'medium' | 'high' }) { + const store = this.get(TASK_STORE); + const userId = this.context.session?.userId; + + if (!userId) { + this.fail(new Error('Authentication required')); + } + + const task = await store.create({ + title: input.title, + priority: input.priority, + status: 'pending', + userId, + }); + + return { + id: task.id, + title: task.title, + priority: task.priority, + status: task.status, + createdAt: task.createdAt, + }; + } +} +``` + +--- + +## Tool: List Tasks + +```typescript +// src/tools/list-tasks.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { TASK_STORE } from '../providers/task-store.provider'; + +@Tool({ + name: 'list_tasks', + description: 'List all tasks for the authenticated user', + inputSchema: { + status: z.enum(['pending', 'in_progress', 'done']).optional().describe('Filter by status'), + }, + outputSchema: { + tasks: z.array( + z.object({ + id: z.string(), + title: z.string(), + priority: z.string(), + status: z.string(), + createdAt: z.string(), + }), + ), + total: z.number(), + }, +}) +export class ListTasksTool extends ToolContext { + async execute(input: { status?: 'pending' | 'in_progress' | 'done' }) { + const store = this.get(TASK_STORE); + const userId = this.context.session?.userId; + + if (!userId) { + this.fail(new Error('Authentication required')); + } + + let tasks = await store.list(userId); + + if (input.status) { + tasks = tasks.filter((t) => t.status === input.status); + } + + return { + tasks: tasks.map((t) => ({ + id: t.id, + title: t.title, + priority: t.priority, + status: t.status, + createdAt: t.createdAt, + })), + total: tasks.length, + }; + } +} +``` + +--- + +## Tool: Update Task + +```typescript +// src/tools/update-task.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { TASK_STORE } from '../providers/task-store.provider'; + +@Tool({ + name: 'update_task', + description: 'Update the status or priority of an existing task', + inputSchema: { + id: z.string().min(1).describe('Task ID to update'), + status: z.enum(['pending', 'in_progress', 'done']).optional().describe('New status'), + priority: z.enum(['low', 'medium', 'high']).optional().describe('New priority'), + }, + outputSchema: { + id: z.string(), + title: z.string(), + priority: z.string(), + status: z.string(), + }, +}) +export class UpdateTaskTool extends ToolContext { + async execute(input: { + id: string; + status?: 'pending' | 'in_progress' | 'done'; + priority?: 'low' | 'medium' | 'high'; + }) { + const store = this.get(TASK_STORE); + const userId = this.context.session?.userId; + + if (!userId) { + this.fail(new Error('Authentication required')); + } + + try { + const updated = await store.update(input.id, userId, { + ...(input.status && { status: input.status }), + ...(input.priority && { priority: input.priority }), + }); + + return { + id: updated.id, + title: updated.title, + priority: updated.priority, + status: updated.status, + }; + } catch (err) { + this.fail(new Error(`Failed to update task: ${String(err)}`)); + } + } +} +``` + +--- + +## Tool: Delete Task + +```typescript +// src/tools/delete-task.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; +import { TASK_STORE } from '../providers/task-store.provider'; + +@Tool({ + name: 'delete_task', + description: 'Delete a task by ID', + inputSchema: { + id: z.string().min(1).describe('Task ID to delete'), + }, + outputSchema: { + deleted: z.boolean(), + id: z.string(), + }, +}) +export class DeleteTaskTool extends ToolContext { + async execute(input: { id: string }) { + const store = this.get(TASK_STORE); + const userId = this.context.session?.userId; + + if (!userId) { + this.fail(new Error('Authentication required')); + } + + try { + await store.delete(input.id, userId); + return { deleted: true, id: input.id }; + } catch (err) { + this.fail(new Error(`Failed to delete task: ${String(err)}`)); + } + } +} +``` + +--- + +## Vercel Deployment Config + +```jsonc +// vercel.json +{ + "version": 2, + "builds": [{ "src": "api/**/*.ts", "use": "@vercel/node" }], + "routes": [{ "src": "/mcp/(.*)", "dest": "/api/mcp" }], + "env": { + "REDIS_URL": "@redis-url", + }, +} +``` + +--- + +## Unit Test: CreateTaskTool + +```typescript +// test/create-task.tool.spec.ts +import { ToolContext } from '@frontmcp/sdk'; +import { CreateTaskTool } from '../src/tools/create-task.tool'; +import { TASK_STORE, type TaskStore } from '../src/providers/task-store.provider'; +import type { Task } from '../src/types/task'; + +describe('CreateTaskTool', () => { + let tool: CreateTaskTool; + let mockStore: jest.Mocked; + + beforeEach(() => { + tool = new CreateTaskTool(); + mockStore = { + create: jest.fn(), + list: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + }); + + function applyContext(userId: string | undefined): void { + const ctx = { + get: jest.fn((token: symbol) => { + if (token === TASK_STORE) return mockStore; + throw new Error(`Unknown token: ${String(token)}`); + }), + tryGet: jest.fn(), + fail: jest.fn((err: Error) => { + throw err; + }), + mark: jest.fn(), + notify: jest.fn(), + respondProgress: jest.fn(), + context: { session: userId ? { userId } : undefined }, + } as unknown as ToolContext; + Object.assign(tool, ctx); + } + + it('should create a task for an authenticated user', async () => { + const mockTask: Task = { + id: 'task-001', + title: 'Write tests', + priority: 'high', + status: 'pending', + userId: 'user-123', + createdAt: '2026-03-27T10:00:00.000Z', + }; + mockStore.create.mockResolvedValue(mockTask); + applyContext('user-123'); + + const result = await tool.execute({ title: 'Write tests', priority: 'high' }); + + expect(result).toEqual({ + id: 'task-001', + title: 'Write tests', + priority: 'high', + status: 'pending', + createdAt: '2026-03-27T10:00:00.000Z', + }); + expect(mockStore.create).toHaveBeenCalledWith({ + title: 'Write tests', + priority: 'high', + status: 'pending', + userId: 'user-123', + }); + }); + + it('should fail when user is not authenticated', async () => { + applyContext(undefined); + + await expect(tool.execute({ title: 'Write tests', priority: 'medium' })).rejects.toThrow('Authentication required'); + }); +}); +``` + +--- + +## E2E Test: Task Manager + +```typescript +// test/tasks.e2e.spec.ts +import { McpTestClient, TestServer, TestTokenFactory } from '@frontmcp/testing'; +import Server from '../src/main'; + +describe('Task Manager E2E', () => { + let client: McpTestClient; + let server: TestServer; + + beforeAll(async () => { + server = await TestServer.start({ command: 'npx tsx src/main.ts' }); + const tokenFactory = new TestTokenFactory(); + const token = await tokenFactory.createTestToken({ sub: 'user-e2e', scopes: ['tasks'] }); + client = await McpTestClient.create({ baseUrl: server.info.baseUrl }).withToken(token).buildAndConnect(); + }); + + afterAll(async () => { + await client.disconnect(); + await server.stop(); + }); + + it('should list all CRUD tools', async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + + expect(names).toContain('create_task'); + expect(names).toContain('list_tasks'); + expect(names).toContain('update_task'); + expect(names).toContain('delete_task'); + }); + + it('should create and list a task', async () => { + const createResult = await client.callTool('create_task', { + title: 'E2E test task', + priority: 'high', + }); + expect(createResult).toBeSuccessful(); + + const listResult = await client.callTool('list_tasks', {}); + expect(listResult).toBeSuccessful(); + + const parsed = JSON.parse(listResult.content[0].text); + expect(parsed.tasks.length).toBeGreaterThan(0); + expect(parsed.tasks.some((t: { title: string }) => t.title === 'E2E test task')).toBe(true); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md b/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md new file mode 100644 index 00000000..04828b3c --- /dev/null +++ b/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md @@ -0,0 +1,292 @@ +# Example: Weather API (Beginner) + +> Skills used: setup-project, create-tool, create-resource, setup-testing, deploy-to-node + +A complete beginner MCP server that exposes a weather lookup tool and a static resource listing supported cities. Demonstrates server setup, Zod input/output schemas, `this.fetch()` for HTTP calls, `this.fail()` for error handling, and both unit and E2E tests. + +--- + +## Project Setup + +```jsonc +// package.json +{ + "name": "weather-api", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "frontmcp build", + "start": "frontmcp start", + "test": "jest --coverage", + }, + "dependencies": { + "@frontmcp/sdk": "^1.0.0", + "zod": "^3.23.0", + }, + "devDependencies": { + "@frontmcp/testing": "^1.0.0", + "jest": "^29.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.4.0", + }, +} +``` + +--- + +## Server Entry Point + +```typescript +// src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; +import { WeatherApp } from './weather.app'; + +@FrontMcp({ + info: { name: 'weather-api', version: '1.0.0' }, + apps: [WeatherApp], +}) +export default class WeatherServer {} +``` + +--- + +## App Registration + +```typescript +// src/weather.app.ts +import { App } from '@frontmcp/sdk'; +import { GetWeatherTool } from './tools/get-weather.tool'; +import { CitiesResource } from './resources/cities.resource'; + +@App({ + name: 'Weather', + description: 'Weather lookup tools and city data', + tools: [GetWeatherTool], + resources: [CitiesResource], +}) +export class WeatherApp {} +``` + +--- + +## Tool: Get Weather + +```typescript +// src/tools/get-weather.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'get_weather', + description: 'Get current weather for a city', + inputSchema: { + city: z.string().min(1).describe('City name'), + units: z.enum(['celsius', 'fahrenheit']).default('celsius').describe('Temperature units'), + }, + outputSchema: { + temperature: z.number(), + condition: z.string(), + humidity: z.number(), + city: z.string(), + }, +}) +export class GetWeatherTool extends ToolContext { + async execute(input: { city: string; units: 'celsius' | 'fahrenheit' }) { + const url = `https://api.weather.example.com/v1/current?city=${encodeURIComponent(input.city)}&units=${input.units}`; + + let response: Response; + try { + response = await this.fetch(url); + } catch (err) { + this.fail(new Error(`Weather API unreachable: ${String(err)}`)); + } + + if (!response.ok) { + this.fail(new Error(`Weather API error: ${response.status} ${response.statusText}`)); + } + + const data = await response.json(); + + return { + temperature: data.temp, + condition: data.condition, + humidity: data.humidity, + city: input.city, + }; + } +} +``` + +--- + +## Resource: Supported Cities + +```typescript +// src/resources/cities.resource.ts +import { Resource, ResourceContext } from '@frontmcp/sdk'; + +const SUPPORTED_CITIES = ['London', 'Tokyo', 'New York', 'Paris', 'Sydney', 'Berlin', 'Toronto', 'Mumbai']; + +@Resource({ + uri: 'weather://cities', + name: 'Supported Cities', + description: 'List of cities with available weather data', + mimeType: 'application/json', +}) +export class CitiesResource extends ResourceContext { + async read() { + return JSON.stringify(SUPPORTED_CITIES); + } +} +``` + +--- + +## Unit Test: GetWeatherTool + +```typescript +// test/get-weather.tool.spec.ts +import { ToolContext } from '@frontmcp/sdk'; +import { GetWeatherTool } from '../src/tools/get-weather.tool'; + +describe('GetWeatherTool', () => { + let tool: GetWeatherTool; + + beforeEach(() => { + tool = new GetWeatherTool(); + }); + + it('should return weather data for a valid city', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + temp: 22, + condition: 'Sunny', + humidity: 45, + }), + } as unknown as Response; + + const ctx = { + fetch: jest.fn().mockResolvedValue(mockResponse), + fail: jest.fn((err: Error) => { + throw err; + }), + mark: jest.fn(), + get: jest.fn(), + tryGet: jest.fn(), + notify: jest.fn(), + respondProgress: jest.fn(), + } as unknown as ToolContext; + Object.assign(tool, ctx); + + const result = await tool.execute({ city: 'London', units: 'celsius' }); + + expect(result).toEqual({ + temperature: 22, + condition: 'Sunny', + humidity: 45, + city: 'London', + }); + expect(ctx.fetch).toHaveBeenCalledWith(expect.stringContaining('city=London')); + }); + + it('should fail when city is empty (Zod validation)', () => { + const { z } = require('zod'); + const schema = z.object({ + city: z.string().min(1), + units: z.enum(['celsius', 'fahrenheit']).default('celsius'), + }); + + expect(() => schema.parse({ city: '' })).toThrow(); + }); + + it('should fail when the weather API returns an error', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + } as unknown as Response; + + const failFn = jest.fn((err: Error) => { + throw err; + }); + const ctx = { + fetch: jest.fn().mockResolvedValue(mockResponse), + fail: failFn, + mark: jest.fn(), + get: jest.fn(), + tryGet: jest.fn(), + notify: jest.fn(), + respondProgress: jest.fn(), + } as unknown as ToolContext; + Object.assign(tool, ctx); + + await expect(tool.execute({ city: 'Atlantis', units: 'celsius' })).rejects.toThrow( + 'Weather API error: 404 Not Found', + ); + + expect(failFn).toHaveBeenCalled(); + }); +}); +``` + +--- + +## E2E Test: Weather Server + +```typescript +// test/weather.e2e.spec.ts +import { McpTestClient, TestServer } from '@frontmcp/testing'; +import Server from '../src/main'; + +describe('Weather Server E2E', () => { + let client: McpTestClient; + let server: TestServer; + + beforeAll(async () => { + server = await TestServer.start({ command: 'npx tsx src/main.ts' }); + client = await McpTestClient.create({ baseUrl: server.info.baseUrl }).buildAndConnect(); + }); + + afterAll(async () => { + await client.disconnect(); + await server.stop(); + }); + + it('should list tools including get_weather', async () => { + const { tools } = await client.listTools(); + + expect(tools.length).toBeGreaterThan(0); + expect(tools).toContainTool('get_weather'); + }); + + it('should call get_weather with a valid city', async () => { + const result = await client.callTool('get_weather', { + city: 'London', + units: 'celsius', + }); + + expect(result).toBeSuccessful(); + expect(result.content[0].text).toBeDefined(); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveProperty('temperature'); + expect(parsed).toHaveProperty('condition'); + expect(parsed).toHaveProperty('humidity'); + expect(parsed).toHaveProperty('city', 'London'); + }); + + it('should read the cities resource', async () => { + const { resources } = await client.listResources(); + const citiesResource = resources.find((r) => r.uri === 'weather://cities'); + expect(citiesResource).toBeDefined(); + + const result = await client.readResource('weather://cities'); + const cities = JSON.parse(result.contents[0].text); + + expect(Array.isArray(cities)).toBe(true); + expect(cities).toContain('London'); + expect(cities).toContain('Tokyo'); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-setup/SKILL.md b/libs/skills/catalog/frontmcp-setup/SKILL.md new file mode 100644 index 00000000..802a71fd --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/SKILL.md @@ -0,0 +1,127 @@ +--- +name: frontmcp-setup +description: "Domain router for project setup and scaffolding \u2014 new projects, project structure, Nx workspaces, storage backends, multi-app composition, and the skills system. Use when starting or organizing a FrontMCP project." +tags: [router, setup, scaffold, project, nx, redis, sqlite, structure, guide] +priority: 10 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/getting-started/quickstart +--- + +# FrontMCP Setup Router + +Entry point for project setup and scaffolding. This skill helps you find the right setup guide based on your project needs — from initial scaffolding to storage backends, project structure, and multi-app composition. + +## When to Use This Skill + +### Must Use + +- Starting a new FrontMCP project from scratch and need to choose between standalone vs Nx monorepo +- Setting up storage backends (Redis, SQLite) for session or state management +- Organizing an existing project and need canonical directory layout guidance + +### Recommended + +- Onboarding to the FrontMCP project structure and naming conventions +- Setting up multi-app composition within a single server +- Understanding the skills system and how to browse, install, and manage skills + +### Skip When + +- You need to build specific components like tools or resources (see `frontmcp-development`) +- You need to configure transport, auth, or throttling (see `frontmcp-config`) +- You need to deploy or build for a target platform (see `frontmcp-deployment`) + +> **Decision:** Use this skill when you need to CREATE or ORGANIZE a project. Use other routers when you need to build, configure, deploy, or test. + +## Prerequisites + +- Node.js 22+ and npm/yarn installed +- `frontmcp` CLI available globally (`npm install -g frontmcp`) + +## Steps + +1. Use the Scenario Routing Table below to find the right setup guide for your task +2. Scaffold your project with `frontmcp create` (standalone) or `frontmcp create --nx` (monorepo) +3. Configure storage and project structure per the relevant reference files +4. Follow the Recommended Reading Order for a complete setup walkthrough + +## Scenario Routing Table + +| Scenario | Reference | Description | +| --------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | +| Scaffold a new project with `frontmcp create` | `references/setup-project.md` | CLI scaffolder, manual setup, deployment-specific config | +| Organize a standalone (non-Nx) project | `references/project-structure-standalone.md` | File layout, naming conventions (`..ts`), folder hierarchy | +| Organize an Nx monorepo | `references/project-structure-nx.md` | apps/, libs/, servers/ layout, generators, dependency rules | +| Set up Redis for production storage | `references/setup-redis.md` | Docker Redis, Vercel KV, pub/sub for subscriptions | +| Set up SQLite for local development | `references/setup-sqlite.md` | WAL mode, migration helpers, encryption | +| Compose multiple apps into one server | `references/multi-app-composition.md` | `@FrontMcp` with multiple `@App` classes, cross-app providers | +| Use Nx build, test, and CI commands | `references/nx-workflow.md` | `nx build`, `nx test`, `nx run-many`, caching, affected commands | +| Browse, install, and manage skills | `references/frontmcp-skills-usage.md` | CLI commands, bundles, categories, search | + +## Recommended Reading Order + +1. **`references/setup-project.md`** — Start here for any new project +2. **`references/project-structure-standalone.md`** or **`references/project-structure-nx.md`** — Choose your layout +3. **`references/setup-redis.md`** or **`references/setup-sqlite.md`** — Add storage if needed +4. **`references/multi-app-composition.md`** — Scale to multiple apps (when needed) +5. **`references/nx-workflow.md`** — Nx-specific build and CI commands (if using Nx) +6. **`references/frontmcp-skills-usage.md`** — Learn the skills system + +## Cross-Cutting Patterns + +| Pattern | Rule | +| -------------- | -------------------------------------------------------------------------------- | +| Project type | Standalone for single-app projects; Nx for multi-app or team projects | +| File naming | `..ts` (e.g., `fetch-weather.tool.ts`) everywhere | +| Test naming | `.spec.ts` extension (not `.test.ts`) | +| Entry point | `main.ts` must `export default` the `@FrontMcp` class | +| Storage choice | Redis for production/serverless; SQLite for local dev/CLI; memory for tests only | +| App boundaries | Each `@App` is a self-contained module; shared logic goes in providers | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------- | ----------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- | +| Project scaffolding | `frontmcp create` or `frontmcp create --nx` | Manual setup from scratch | CLI sets up correct structure, dependencies, and config files | +| Entry point | `export default class MyServer` in `main.ts` | Named export or no default export | FrontMCP loads the default export at startup | +| Storage in production | Redis or platform-native (Vercel KV, DynamoDB) | Memory store or SQLite | Memory is lost on restart; SQLite doesn't work on serverless | +| Multi-app composition | Separate `@App` classes composed in `@FrontMcp` | One giant `@App` with all components | Separate apps enable independent testing and modular architecture | +| File organization | Feature folders for 10+ components | Flat `tools/` directory with dozens of files | Feature folders make domain boundaries visible | + +## Verification Checklist + +### Project Structure + +- [ ] `main.ts` exists with `export default` of `@FrontMcp` class +- [ ] At least one `@App` class registered in the server +- [ ] Files follow `..ts` naming convention +- [ ] Test files use `.spec.ts` extension + +### Storage + +- [ ] Storage backend chosen and configured (Redis/SQLite/memory) +- [ ] Connection string in environment variables, not hardcoded +- [ ] Storage accessible from the server process + +### Build and Dev + +- [ ] `frontmcp dev` starts successfully with file watching +- [ ] `frontmcp build --target ` completes without errors +- [ ] Tests pass with `jest` or `nx test` + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------ | -------------------------------- | --------------------------------------------------------------------- | +| `frontmcp create` fails | Missing Node.js 22+ or npm/yarn | Install Node.js 22+ and ensure npm/yarn is available | +| Server fails to start | `main.ts` missing default export | Add `export default MyServerClass` to `main.ts` | +| Redis connection refused | Redis not running or wrong URL | Start Redis (`docker compose up redis`) or fix `REDIS_URL` env var | +| Nx generator not found | `@frontmcp/nx` not installed | Run `npm install -D @frontmcp/nx` | +| Skills not loading | Skills placed in wrong directory | Catalog skills go in top-level `skills/`, app skills in `src/skills/` | + +## Reference + +- [Getting Started](https://docs.agentfront.dev/frontmcp/getting-started/quickstart) +- Domain routers: `frontmcp-development`, `frontmcp-deployment`, `frontmcp-testing`, `frontmcp-config`, `frontmcp-guides` diff --git a/libs/skills/catalog/frontmcp-setup/references/frontmcp-skills-usage.md b/libs/skills/catalog/frontmcp-setup/references/frontmcp-skills-usage.md new file mode 100644 index 00000000..9f7d25de --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/frontmcp-skills-usage.md @@ -0,0 +1,265 @@ +# FrontMCP Skills — Search, Install, and Usage + +FrontMCP ships with a catalog of development skills that teach AI agents (Claude Code, Codex) how to build FrontMCP servers. You can deliver these skills **statically** (copy to disk) or **dynamically** (search on demand via CLI). + +## When to Use This Skill + +### Must Use + +- Setting up a new FrontMCP project and need to discover which skills to install for your workflow +- Configuring AI-assisted development (Claude Code or Codex) with FrontMCP skill files for the first time +- Deciding between static skill installation and dynamic on-demand search for your team + +### Recommended + +- Exploring the FrontMCP skill catalog to find skills for a specific topic (auth, deployment, plugins, etc.) +- Onboarding a new team member who needs to understand how FrontMCP skills are delivered and consumed +- Optimizing token usage by switching from fully-static to a hybrid static/dynamic skill strategy + +### Skip When + +- You already know which specific skill you need and want to learn its content (use that skill directly, e.g., `frontmcp-development` or `frontmcp-config`) +- You are scaffolding a brand-new FrontMCP project from scratch (use `frontmcp-setup` instead) +- You need to create a custom skill for your own organization (use the `create-skill` reference in `frontmcp-development`) + +> **Decision:** Use this skill when you need to understand the skills system itself -- how to browse, install, manage, and deliver FrontMCP skills to AI agents. + +## Available Skills + +The catalog contains 6 router skills, each covering a domain: + +| Skill Name | Category | Description | +| ---------------------- | ----------- | ------------------------------------------------------------------------------ | +| `frontmcp-setup` | setup | Project setup, scaffolding, Nx workspaces, storage backends | +| `frontmcp-development` | development | Creating tools, resources, prompts, agents, providers, jobs, workflows, skills | +| `frontmcp-deployment` | deployment | Build and deploy to Node, Vercel, Lambda, Cloudflare, CLI, browser, SDK | +| `frontmcp-testing` | testing | Testing with Jest and @frontmcp/testing | +| `frontmcp-config` | config | Transport, HTTP, throttle, elicitation, auth, sessions, storage | +| `frontmcp-guides` | guides | End-to-end examples and best practices | + +Each router skill contains a SKILL.md with a routing table and a `references/` directory with detailed reference files. + +## Quick Start + +```bash +# List all skills +frontmcp skills list + +# List skills by category +frontmcp skills list --category development + +# Show full skill content +frontmcp skills show frontmcp-development + +# Install a skill for Claude Code +frontmcp skills install frontmcp-development --provider claude + +# Install a skill for Codex +frontmcp skills install frontmcp-setup --provider codex +``` + +## CLI Commands + +### `frontmcp skills search ` + +Semantic search through the catalog using weighted text matching (description 3x, tags 2x, name 1x): + +```bash +frontmcp skills search "authentication oauth" +frontmcp skills search "deploy vercel" --category deployment +frontmcp skills search "plugin hooks" --tag middleware --limit 5 +``` + +### `frontmcp skills list` + +List all skills, optionally filtered: + +```bash +frontmcp skills list # All skills +frontmcp skills list --category development # Development skills only +frontmcp skills list --tag redis # Skills tagged with redis +frontmcp skills list --bundle recommended # Recommended bundle +``` + +### `frontmcp skills show ` + +Print the full SKILL.md content to stdout — useful for piping to AI context: + +```bash +frontmcp skills show frontmcp-development # Print development skill +frontmcp skills show frontmcp-config # Print config skill +``` + +### `frontmcp skills install ` + +Copy a skill to a provider-specific directory: + +```bash +# Claude Code — installs to .claude/skills//SKILL.md +frontmcp skills install frontmcp-development --provider claude + +# Codex — installs to .codex/skills//SKILL.md +frontmcp skills install frontmcp-setup --provider codex + +# Custom directory +frontmcp skills install frontmcp-guides --dir ./my-skills +``` + +## Two Approaches: Static vs Dynamic + +### Static Installation (Copy to Disk) + +Install skills once — they live in your project and are always available: + +```bash +# Install for Claude Code +frontmcp skills install frontmcp-setup --provider claude +frontmcp skills install frontmcp-development --provider claude +frontmcp skills install frontmcp-config --provider claude + +# Install for Codex +frontmcp skills install frontmcp-development --provider codex +``` + +**Directory structure after install:** + +```text +my-project/ +├── .claude/ +│ └── skills/ +│ ├── frontmcp-setup/ +│ │ ├── SKILL.md +│ │ └── references/ +│ ├── frontmcp-development/ +│ │ ├── SKILL.md +│ │ └── references/ +│ └── frontmcp-config/ +│ ├── SKILL.md +│ └── references/ +├── .codex/ +│ └── skills/ +│ └── frontmcp-development/ +│ ├── SKILL.md +│ └── references/ +└── src/ + └── ... +``` + +### Dynamic Search (On-Demand via CLI) + +Use the CLI to search and show skills on demand — no installation needed: + +```bash +# Search for what you need +frontmcp skills search "how to create a tool with zod" + +# Pipe skill content directly into context +frontmcp skills show frontmcp-development +``` + +This works because `frontmcp skills show` outputs the full SKILL.md content to stdout. + +## Comparison: Static vs Dynamic + +| Aspect | Static Install | Dynamic CLI Search | +| ----------------- | ------------------------------------- | ----------------------------------------------- | +| **Setup** | `frontmcp skills install ` once | No setup — just use `frontmcp skills search` | +| **Availability** | Always loaded by AI agent | On-demand, requires CLI invocation | +| **Context usage** | Skills in system prompt (uses tokens) | Only loaded when searched (saves tokens) | +| **Updates** | Re-install to update | Uses catalog bundled with the installed package | +| **Offline** | Works offline after install | Needs catalog available | +| **Best for** | Core skills you use daily | Occasional reference, exploration | +| **Token cost** | Higher (all installed skills loaded) | Lower (only searched skills loaded) | + +### Recommended Approach + +**Install 2-4 core skills statically** for your most common workflows, and use dynamic search for everything else: + +```bash +# Core skills — install statically +frontmcp skills install frontmcp-setup --provider claude +frontmcp skills install frontmcp-development --provider claude +frontmcp skills install frontmcp-config --provider claude + +# Everything else — search on demand +frontmcp skills search "deploy to vercel" +frontmcp skills search "rate limiting" +frontmcp skills show frontmcp-deployment +``` + +## Provider Directories + +| Provider | Install directory | Auto-loaded by | +| ----------- | -------------------------------- | ------------------------- | +| Claude Code | `.claude/skills//SKILL.md` | Claude Code system prompt | +| Codex | `.codex/skills//SKILL.md` | Codex agent context | + +## Bundle Presets + +When scaffolding a project, use `--skills` to install a preset bundle: + +```bash +# Recommended bundle (core skills for the deployment target) +frontmcp create my-app --skills recommended + +# Minimal bundle (just project setup + create-tool) +frontmcp create my-app --skills minimal + +# Full bundle (all skills) +frontmcp create my-app --skills full + +# No skills +frontmcp create my-app --skills none +``` + +## Available Categories + +```bash +frontmcp skills list --category setup # Project setup and scaffolding +frontmcp skills list --category config # Server configuration (transport, HTTP, throttle, auth) +frontmcp skills list --category development # Creating tools, resources, prompts, agents, skills +frontmcp skills list --category deployment # Build and deploy (node, vercel, lambda, cloudflare, cli, browser, sdk) +frontmcp skills list --category testing # Testing with Jest and @frontmcp/testing +frontmcp skills list --category guides # End-to-end examples and best practices +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Installing a skill | `frontmcp skills install frontmcp-development --provider claude` | `cp node_modules/.../SKILL.md .claude/skills/` | The CLI handles directory creation, naming, and reference files automatically | +| Searching skills | `frontmcp skills search "oauth authentication"` | `frontmcp skills list \| grep oauth` | Search uses weighted text matching (description 3x, tags 2x) for better relevance | +| Choosing delivery mode | Install 2-4 core skills statically; search the rest on demand | Install every skill statically into the project | Static skills consume tokens on every agent invocation; keep the set small | +| Updating an installed skill | `frontmcp skills install frontmcp-development --provider claude` (re-run) | Manually editing the installed SKILL.md file | Re-installing overwrites with the catalog bundled in the installed CLI version and preserves structure | +| Filtering by category | `frontmcp skills list --category deployment` | `frontmcp skills search "deployment"` | `--category` uses the manifest taxonomy; search is for free-text queries | + +## Verification Checklist + +### Configuration + +- [ ] FrontMCP CLI is installed and available on PATH (`frontmcp --version`) +- [ ] Target provider directory exists or will be created (`.claude/skills/` or `.codex/skills/`) +- [ ] Desired skills are listed in `frontmcp skills list` output +- [ ] Bundle preset matches project needs (`minimal`, `recommended`, or `full`) + +### Runtime + +- [ ] Installed skills appear in the correct provider directory after `frontmcp skills install` +- [ ] `frontmcp skills show ` outputs the full SKILL.md content to stdout +- [ ] `frontmcp skills search ` returns relevant results ranked by relevance +- [ ] AI agent (Claude Code or Codex) loads installed skills in its system prompt context + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `frontmcp skills search` returns no results | Query terms do not match any skill name, description, or tags | Broaden the query, try synonyms, or use `frontmcp skills list` to browse all available skills | +| Installed skill not picked up by Claude Code | Skill was installed to wrong directory or provider flag was omitted | Re-install with `--provider claude` and verify the file exists at `.claude/skills//SKILL.md` | +| `frontmcp skills install` fails with permission error | Target directory is read-only or owned by a different user | Check directory permissions; use `--dir` flag to specify an alternative writable path | +| Skill content is outdated after a CLI upgrade | Static installs are point-in-time snapshots of the catalog | Re-run `frontmcp skills install --provider claude` to fetch the latest version | +| Too many tokens consumed by agent context | All skills installed statically, inflating the system prompt | Uninstall rarely-used skills and switch to dynamic search (`frontmcp skills search`) for occasional needs | + +## Reference + +- **Docs:** +- **Related skills:** `frontmcp-setup`, `frontmcp-development`, `frontmcp-config`, `frontmcp-deployment` diff --git a/libs/skills/catalog/frontmcp-setup/references/multi-app-composition.md b/libs/skills/catalog/frontmcp-setup/references/multi-app-composition.md new file mode 100644 index 00000000..d821e522 --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/multi-app-composition.md @@ -0,0 +1,400 @@ +# Multi-App Composition + +Compose multiple `@App` classes into a single `@FrontMcp` server. Each app contributes its own tools, resources, prompts, skills, and plugins. Apps can be local classes, npm packages loaded at runtime, or remote MCP servers proxied through your gateway. + +## When to Use This Skill + +### Must Use + +- Composing multiple `@App` classes with separate domains into a single `@FrontMcp` server +- Aggregating external MCP servers via `app.remote()` or npm packages via `app.esm()` into a unified gateway +- Configuring per-app authentication modes (e.g., one app public, another requiring OAuth) + +### Recommended + +- Setting up shared tools, resources, or plugins that span all apps in the server +- Isolating apps with `standalone: true` or `standalone: 'includeInParent'` for scoped auth or session separation +- Namespacing tools from multiple apps or remote servers to prevent naming collisions + +### Skip When + +- Your server has a single logical domain with one `@App` class (see `project-structure-standalone`) +- You are scaffolding an Nx monorepo workspace and need generator commands (see `project-structure-nx`) +- You need to create individual tools, resources, or prompts rather than compose apps (see `create-tool`) + +> **Decision:** Use this skill when you need to compose two or more apps -- local, ESM, or remote -- into a single FrontMCP server with shared or scoped capabilities. + +## Local Apps + +A local app is a TypeScript class decorated with `@App`. It declares tools, resources, prompts, skills, plugins, providers, agents, jobs, and workflows inline. + +The `@App` decorator accepts `LocalAppMetadata`: + +```typescript +import { App } from '@frontmcp/sdk'; + +@App({ + id: 'billing', // string (optional) - unique identifier + name: 'Billing', // string (required) - display name + description: 'Payment tools', // string (optional) + tools: [ChargeCardTool, RefundTool], + resources: [InvoiceResource], + prompts: [BillingSummaryPrompt], + skills: [BillingWorkflowSkill], + plugins: [AuditLogPlugin], // scoped to this app only + providers: [StripeProvider], + agents: [BillingAgent], + jobs: [ReconcileJob], + workflows: [MonthlyBillingWorkflow], + auth: { mode: 'remote', idpProviderUrl: 'https://auth.billing.com' }, + standalone: false, // default - included in multi-app server +}) +export class BillingApp {} +``` + +Register it in the server: + +```typescript +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [BillingApp, InventoryApp, SupportApp], +}) +export default class Server {} +``` + +## ESM Apps (npm Packages) + +Load an `@App`-decorated class from an npm package at runtime using `app.esm()`. The package is fetched, cached, and its default export is treated as a local app. + +```typescript +import { FrontMcp, app } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [app.esm('@acme/tools@^1.0.0', { namespace: 'acme' }), app.esm('@org/analytics@latest')], +}) +export default class Server {} +``` + +`app.esm(specifier, options?)` accepts a package specifier (e.g., `'@acme/tools@^1.0.0'`) and optional `EsmAppOptions`: + +| Option | Type | Description | +| ------------- | ------------------------------------------- | -------------------------------------------------- | +| `name` | `string` | Override the auto-derived app name | +| `namespace` | `string` | Namespace prefix for tools, resources, and prompts | +| `description` | `string` | Human-readable description | +| `standalone` | `boolean \| 'includeInParent'` | Scope isolation mode (default: `false`) | +| `loader` | `PackageLoader` | Custom registry/bundle URLs and auth token | +| `autoUpdate` | `{ enabled: boolean; intervalMs?: number }` | Background version polling | +| `importMap` | `Record` | Import map overrides for ESM resolution | +| `filter` | `AppFilterConfig` | Include/exclude filter for primitives | + +Example with custom loader and auto-update: + +```typescript +app.esm('@internal/tools@^2.0.0', { + namespace: 'internal', + loader: { + url: 'https://npm.internal.corp', + token: process.env['NPM_TOKEN'], + }, + autoUpdate: { enabled: true, intervalMs: 300_000 }, +}); +``` + +## Remote Apps (External MCP Servers) + +Proxy tools, resources, and prompts from an external MCP server using `app.remote()`. The gateway connects via Streamable HTTP (with SSE fallback) and exposes the remote primitives as if they were local. + +```typescript +import { FrontMcp, app } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [ + app.remote('https://api.example.com/mcp', { namespace: 'api' }), + app.remote('https://slack-mcp.internal/mcp', { + namespace: 'slack', + remoteAuth: { + mode: 'static', + credentials: { type: 'bearer', value: process.env['SLACK_TOKEN']! }, + }, + }), + ], +}) +export default class Server {} +``` + +`app.remote(url, options?)` accepts a URL and optional `RemoteUrlAppOptions`: + +| Option | Type | Description | +| ------------------ | ------------------------------ | --------------------------------------------------------- | +| `name` | `string` | Override the auto-derived app name (defaults to hostname) | +| `namespace` | `string` | Namespace prefix for tools, resources, and prompts | +| `description` | `string` | Human-readable description | +| `standalone` | `boolean \| 'includeInParent'` | Scope isolation mode (default: `false`) | +| `transportOptions` | `RemoteTransportOptions` | Timeout, retries, headers, SSE fallback | +| `remoteAuth` | `RemoteAuthConfig` | Auth config: `'static'`, `'forward'`, or `'oauth'` | +| `refreshInterval` | `number` | Interval (ms) to refresh capabilities from remote | +| `cacheTTL` | `number` | TTL (ms) for cached capabilities (default: 60000) | +| `filter` | `AppFilterConfig` | Include/exclude filter for primitives | + +`RemoteTransportOptions` fields: + +| Field | Type | Default | Description | +| --------------- | ------------------------ | ------- | ---------------------------------------- | +| `timeout` | `number` | `30000` | Request timeout in ms | +| `retryAttempts` | `number` | `3` | Retry attempts for failed requests | +| `retryDelayMs` | `number` | `1000` | Delay between retries in ms | +| `fallbackToSSE` | `boolean` | `true` | Fallback to SSE if Streamable HTTP fails | +| `headers` | `Record` | - | Additional headers for all requests | + +`RemoteAuthConfig` modes: + +- `{ mode: 'static', credentials: { type: 'bearer' | 'basic' | 'apiKey', value: string } }` -- static credentials for trusted internal services +- `{ mode: 'forward', tokenClaim?: string, headerName?: string }` -- forward the gateway user's token to the remote server +- `{ mode: 'oauth' }` -- let the remote server handle its own OAuth flow + +## Scope Isolation + +Each `@App` gets its own Scope. The `standalone` property on `LocalAppMetadata` (and on ESM/remote options) controls how that scope relates to the parent server: + +```typescript +// standalone: false (default) +// App is included in the multi-app server. Its tools are merged +// into the unified tool list and namespaced by app id. +@App({ name: 'Billing', standalone: false, tools: [ChargeTool] }) +class BillingApp {} + +// standalone: true +// App runs as a completely separate scope. It is NOT visible +// in the parent server's tool/resource lists. Useful for apps +// that need total isolation (separate auth, separate session). +@App({ name: 'Admin', standalone: true, tools: [ResetTool] }) +class AdminApp {} + +// standalone: 'includeInParent' +// App gets its own separate scope but its tools ARE visible +// in the parent server under the app name prefix. Best of both worlds: +// isolation with visibility. +@App({ name: 'Analytics', standalone: 'includeInParent', tools: [QueryTool] }) +class AnalyticsApp {} +``` + +The type is: `standalone?: 'includeInParent' | boolean` (defaults to `false`). + +## Tool Namespacing + +When multiple apps are composed, tools are automatically namespaced by app id to prevent naming collisions. The format is `appId:toolName`. + +```typescript +@App({ id: 'billing', name: 'Billing', tools: [ChargeTool] }) +class BillingApp {} +// Tool is exposed as: billing:charge_card + +@App({ id: 'inventory', name: 'Inventory', tools: [CheckStockTool] }) +class InventoryApp {} +// Tool is exposed as: inventory:check_stock +``` + +For remote and ESM apps, the `namespace` option controls the prefix: + +```typescript +app.remote('https://api.example.com/mcp', { namespace: 'api' }); +// Remote tools are exposed as: api:tool_name + +app.esm('@acme/tools@^1.0.0', { namespace: 'acme' }); +// ESM tools are exposed as: acme:tool_name +``` + +## Shared Tools + +Tools declared directly on `@FrontMcp` (not inside an `@App`) are shared across all apps. They are merged additively with app-specific tools and are available without a namespace prefix. + +```typescript +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [BillingApp, InventoryApp], + tools: [HealthCheckTool, WhoAmITool], // shared tools - available to all apps +}) +export default class Server {} +``` + +The same pattern works for shared resources and shared skills: + +```typescript +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [BillingApp], + tools: [HealthCheckTool], // shared tools + resources: [ConfigResource], // shared resources + skills: [OnboardingSkill], // shared skills +}) +export default class Server {} +``` + +## Shared Plugins + +Plugins declared on `@FrontMcp` are server-level plugins instantiated per scope. Every app in the server sees these plugins. Use them for cross-cutting concerns like logging, tracing, PII reduction, and policy enforcement. + +```typescript +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [BillingApp, InventoryApp], + plugins: [TracingPlugin, PiiRedactionPlugin], // all apps see these +}) +export default class Server {} +``` + +## Per-App Auth + +Each `@App` can have its own `auth` configuration, overriding the server-level auth. This allows mixed authentication modes within a single server -- for example, one app public and another requiring OAuth. + +```typescript +// Public app - no auth required +@App({ + name: 'Public', + tools: [EchoTool, HealthTool], + auth: { mode: 'public' }, +}) +class PublicApp {} + +// Protected app - requires OAuth +@App({ + name: 'Admin', + tools: [UserManagementTool, AuditLogTool], + auth: { + mode: 'remote', + idpProviderUrl: 'https://auth.example.com', + idpExpectedAudience: 'admin-api', + }, +}) +class AdminApp {} + +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [PublicApp, AdminApp], + // Server-level auth acts as the default for apps without their own auth + auth: { mode: 'public' }, +}) +export default class Server {} +``` + +If an app does not specify `auth`, it inherits the server-level configuration. The `auth` field accepts `AuthOptionsInput`. + +## Per-App Plugins + +Plugins declared on `@App` are scoped to that app only. They do not affect other apps in the server. Use per-app plugins for app-specific middleware, caching, or domain logic. + +```typescript +@App({ + name: 'Billing', + tools: [ChargeTool], + plugins: [BillingAuditPlugin, RateLimitPlugin], // only Billing sees these +}) +class BillingApp {} + +@App({ + name: 'Inventory', + tools: [CheckStockTool], + plugins: [InventoryCachePlugin], // only Inventory sees this +}) +class InventoryApp {} +``` + +## Full Composition Example + +Combining all patterns into a single server: + +```typescript +import 'reflect-metadata'; +import { FrontMcp, App, app } from '@frontmcp/sdk'; + +// Local app with per-app auth and plugins +@App({ + name: 'Billing', + tools: [ChargeTool, RefundTool], + plugins: [BillingAuditPlugin], + auth: { mode: 'remote', idpProviderUrl: 'https://auth.billing.com' }, +}) +class BillingApp {} + +// Local public app +@App({ + name: 'Public', + tools: [EchoTool], + auth: { mode: 'public' }, +}) +class PublicApp {} + +// Standalone app with its own isolated scope +@App({ + name: 'Admin', + tools: [ResetTool], + standalone: true, +}) +class AdminApp {} + +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [ + BillingApp, + PublicApp, + AdminApp, + app.esm('@acme/crm@^2.0.0', { namespace: 'crm' }), + app.remote('https://slack-mcp.example.com/mcp', { namespace: 'slack' }), + ], + tools: [HealthCheckTool], // shared across all apps + plugins: [TracingPlugin, PiiPlugin], // shared across all apps + providers: [DatabaseProvider], // shared across all apps +}) +export default class Server {} +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Shared tools | `@FrontMcp({ tools: [HealthCheckTool] })` (server-level) | Duplicating the tool class in every `@App` `tools` array | Server-level tools are automatically shared across all apps without duplication | +| App namespacing | `@App({ id: 'billing', name: 'Billing', tools: [ChargeTool] })` | Omitting `id` when multiple apps have tools with the same name | The `id` field controls the namespace prefix (`billing:charge_card`); without it collisions occur | +| Remote auth | `remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: token } }` | Passing the token directly as a string to `remoteAuth` | `remoteAuth` expects a structured object with `mode` and `credentials` fields | +| Standalone isolation | `standalone: true` for fully isolated apps | `standalone: true` when you still want tools visible in the parent server | Use `standalone: 'includeInParent'` to get scope isolation with parent visibility | +| Per-app auth | `auth: { mode: 'remote', idpProviderUrl: '...' }` on `@App` | Configuring auth only at the `@FrontMcp` level when apps need different modes | Apps without their own `auth` inherit server-level config; set per-app `auth` for mixed modes | + +## Verification Checklist + +### Configuration + +- [ ] `@FrontMcp` `apps` array includes all local, ESM, and remote apps +- [ ] Each `@App` has a unique `id` (or unique `name` if `id` is omitted) +- [ ] `namespace` is set on ESM and remote apps to prevent tool name collisions +- [ ] Server-level `tools`, `plugins`, and `providers` are declared for shared capabilities + +### Runtime + +- [ ] All app tools appear in `tools/list` with correct namespace prefixes +- [ ] Shared tools appear without a namespace prefix +- [ ] `standalone: true` apps are isolated and do not appear in parent tool listing +- [ ] `standalone: 'includeInParent'` apps have isolated scope but visible tools +- [ ] Per-app auth modes are enforced independently per app + +### Remote Apps + +- [ ] `app.remote()` URL is reachable and returns valid MCP capabilities +- [ ] `remoteAuth` credentials are correct and not expired +- [ ] `fallbackToSSE` is enabled if the remote server does not support Streamable HTTP + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Tool name collision between apps | Multiple apps register tools with the same name and no `id` | Set unique `id` on each `@App` or use `namespace` on ESM/remote apps | +| Remote app tools not appearing | Remote server is unreachable or returns empty capabilities | Verify the URL, check `transportOptions.timeout`, and ensure `remoteAuth` is correct | +| Shared plugin not applied to an app | Plugin declared on `@App` instead of `@FrontMcp` | Move the plugin to the `@FrontMcp` `plugins` array for server-wide application | +| `standalone: true` app tools not visible | Standalone apps are fully isolated by design | Use `standalone: 'includeInParent'` to expose tools in the parent server while keeping scope isolation | +| Per-app auth not working | App does not declare its own `auth` field | Add `auth` configuration directly on the `@App` decorator; omitted `auth` inherits server-level defaults | + +## Reference + +- [Multi-App Composition Documentation](https://docs.agentfront.dev/frontmcp/features/multi-app-composition) +- Related skills: `project-structure-standalone`, `project-structure-nx`, `configure-auth`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-setup/references/nx-workflow.md b/libs/skills/catalog/frontmcp-setup/references/nx-workflow.md new file mode 100644 index 00000000..1cbf9f87 --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/nx-workflow.md @@ -0,0 +1,414 @@ +# Nx Monorepo Workflow for FrontMCP + +Use the `@frontmcp/nx` plugin to scaffold, build, test, and deploy FrontMCP projects in an Nx monorepo. The plugin provides generators for every FrontMCP primitive (tools, resources, prompts, skills, agents, plugins, adapters, providers, flows, jobs, workflows) and deployment shells for multiple targets. + +## When to Use This Skill + +### Must Use + +- Your project contains multiple apps or shared libraries in a monorepo structure +- You need fine-grained build caching and affected-only testing in CI +- You are scaffolding a new FrontMCP workspace with multiple deployment targets + +### Recommended + +- You want generator-based scaffolding for every FrontMCP primitive (tools, resources, prompts, skills, etc.) +- You need to visualize and manage complex dependency graphs across projects +- Your team benefits from parallelized builds and consistent project structure + +### Skip When + +- Your project is a single standalone MCP server with no shared libraries -- use `frontmcp create` instead +- You are adding FrontMCP to an existing non-Nx build system (Turborepo, Lerna) -- use `setup-project` instead +- You only need to configure storage or auth without workspace scaffolding -- use `setup-sqlite` or `setup-redis` instead + +> **Decision:** Use this skill when managing a multi-project FrontMCP monorepo; skip it for single-server projects. + +## Step 1 -- Initialize the Workspace + +### Option A: Scaffold a new Nx workspace with the FrontMCP CLI + +```bash +npx frontmcp create my-project --nx +``` + +This creates a full Nx workspace with `@frontmcp/nx` pre-installed, sample app, and workspace configuration. + +### Option B: Add FrontMCP to an existing Nx workspace + +Install the plugin: + +```bash +yarn add -D @frontmcp/nx +``` + +Then initialize the workspace structure: + +```bash +nx g @frontmcp/nx:workspace my-workspace +``` + +The workspace generator creates the directory structure (`apps/`, `libs/`, `servers/`) and base configuration. It accepts these options: + +| Option | Type | Default | Description | +| ----------------- | --------------------------- | ---------- | -------------------------------- | +| `name` | `string` | (required) | Workspace name | +| `packageManager` | `'npm' \| 'yarn' \| 'pnpm'` | `'npm'` | Package manager to use | +| `skipInstall` | `boolean` | `false` | Skip package installation | +| `skipGit` | `boolean` | `false` | Skip git initialization | +| `createSampleApp` | `boolean` | `true` | Create a sample demo application | + +## Step 2 -- Generate Apps and Libraries + +### Generate an App + +```bash +nx g @frontmcp/nx:app my-app +``` + +Creates an `@App`-decorated class in `apps/my-app/` with a tools directory, barrel exports, and project configuration. The `--project` flag is not needed for app generation since the app is the project. + +### Generate a Shared Library + +```bash +nx g @frontmcp/nx:lib my-lib +``` + +Creates a shared library in `libs/my-lib/` with TypeScript configuration, Jest setup, and barrel exports. Use libraries for shared providers, utilities, and types that multiple apps consume. + +### Generate a Server (Deployment Shell) + +```bash +nx g @frontmcp/nx:server my-server --deploymentTarget=node --apps=my-app +``` + +Creates a `@FrontMcp`-decorated server class in `servers/my-server/` that composes one or more apps. The server is the deployment unit. + +| Option | Type | Default | Description | +| ------------------ | ------------------------------------------------ | ---------------- | ------------------------------------- | +| `name` | `string` | (required) | Server name | +| `apps` | `string` | (required) | Comma-separated app names to compose | +| `deploymentTarget` | `'node' \| 'vercel' \| 'lambda' \| 'cloudflare'` | `'node'` | Deployment target platform | +| `directory` | `string` | `servers/` | Override the default directory | +| `redis` | `'docker' \| 'existing' \| 'none'` | `'none'` | Redis setup option (node target only) | +| `skills` | `'recommended' \| 'minimal' \| 'full' \| 'none'` | `'recommended'` | Skills bundle to include | + +## Step 3 -- Generate MCP Primitives + +All primitive generators require `--project` to specify which app receives the generated file. Each generator creates the implementation file, a `.spec.ts` test file, and updates barrel exports. + +### Tool + +```bash +nx g @frontmcp/nx:tool my-tool --project=my-app +``` + +Creates a `@Tool`-decorated class extending `ToolContext` in `apps/my-app/src/tools/`. Use the `--directory` option to place it in a subdirectory within `src/tools/`. + +### Resource + +```bash +nx g @frontmcp/nx:resource my-resource --project=my-app +``` + +Creates a `@Resource`-decorated class extending `ResourceContext` in `apps/my-app/src/resources/`. + +### Prompt + +```bash +nx g @frontmcp/nx:prompt my-prompt --project=my-app +``` + +Creates a `@Prompt`-decorated class extending `PromptContext` in `apps/my-app/src/prompts/`. + +### Skill (Class-Based) + +```bash +nx g @frontmcp/nx:skill my-skill --project=my-app +``` + +Creates a `@Skill`-decorated class extending `SkillContext` in `apps/my-app/src/skills/`. + +### Skill (SKILL.md Directory) + +```bash +nx g @frontmcp/nx:skill-dir my-skill --project=my-app +``` + +Creates a `SKILL.md`-based skill directory in `apps/my-app/src/skills/my-skill/` with a template SKILL.md file. Use this for declarative skills that are defined by markdown instructions rather than code. + +### Agent + +```bash +nx g @frontmcp/nx:agent my-agent --project=my-app +``` + +Creates an `@Agent`-decorated class in `apps/my-app/src/agents/`. Agents are autonomous AI components with their own LLM providers and isolated scopes, automatically exposed as `use-agent:` tools. + +### Plugin + +```bash +nx g @frontmcp/nx:plugin my-plugin --project=my-app +``` + +Creates a `@Plugin` class extending `DynamicPlugin` in `apps/my-app/src/plugins/`. Plugins participate in lifecycle events and can contribute additional capabilities. + +### Adapter + +```bash +nx g @frontmcp/nx:adapter my-adapter --project=my-app +``` + +Creates an `@Adapter` class extending `DynamicAdapter` in `apps/my-app/src/adapters/`. Adapters convert external definitions (OpenAPI, Lambda, etc.) into generated tools, resources, and prompts. + +### Provider + +```bash +nx g @frontmcp/nx:provider my-provider --project=my-app +``` + +Creates a `@Provider` class in `apps/my-app/src/providers/`. Providers are named singletons resolved via DI (e.g., database pools, API clients, config). + +### Flow + +```bash +nx g @frontmcp/nx:flow my-flow --project=my-app +``` + +Creates a `@Flow` class extending `FlowBase` in `apps/my-app/src/flows/`. Flows define execution pipelines with hooks and stages. + +### Job + +```bash +nx g @frontmcp/nx:job my-job --project=my-app +``` + +Creates a `@Job` class in `apps/my-app/src/jobs/`. Jobs are pure executable units with strict input/output schemas. + +### Workflow + +```bash +nx g @frontmcp/nx:workflow my-workflow --project=my-app +``` + +Creates a `@Workflow` class in `apps/my-app/src/workflows/`. Workflows connect jobs into managed steps with triggers. + +### Auth Provider + +```bash +nx g @frontmcp/nx:auth-provider my-auth --project=my-app +``` + +Creates an `@AuthProvider` class in `apps/my-app/src/auth-providers/`. Auth providers handle session-based authentication (e.g., GitHub OAuth, Google OAuth). + +## Step 4 -- Build and Test + +### Build a Single Project + +```bash +nx build my-server +``` + +Builds the server and all its dependencies in the correct order. Nx caches build outputs so subsequent builds of unchanged projects are instant. + +### Test a Single Project + +```bash +nx test my-app +``` + +Runs Jest tests for the specified project. Test files must use `.spec.ts` extension (not `.test.ts`). + +### Build All Projects + +```bash +nx run-many -t build +``` + +Builds every project in the workspace. Nx parallelizes independent builds automatically. + +### Test All Projects + +```bash +nx run-many -t test +``` + +Runs tests for every project in the workspace. + +### Test Only Affected Projects + +```bash +nx affected -t test +``` + +Runs tests only for projects affected by changes since the last commit (or since the base branch). This is the fastest way to validate changes in CI. + +### Build Only Affected Projects + +```bash +nx affected -t build +``` + +### Run Multiple Targets + +```bash +nx run-many -t build,test,lint +``` + +## Step 5 -- Workspace Structure + +After scaffolding, the workspace follows this directory layout: + +```text +my-project/ + apps/ + my-app/ + src/ + tools/ # @Tool classes + resources/ # @Resource classes + prompts/ # @Prompt classes + skills/ # @Skill classes and SKILL.md dirs + agents/ # @Agent classes + plugins/ # @Plugin classes + adapters/ # @Adapter classes + providers/ # @Provider classes + flows/ # @Flow classes + jobs/ # @Job classes + workflows/ # @Workflow classes + auth-providers/ # @AuthProvider classes + my-app.app.ts # @App class + index.ts # barrel exports + project.json + tsconfig.json + jest.config.ts + libs/ + my-lib/ + src/ + index.ts + project.json + servers/ + my-server/ + src/ + main.ts # @FrontMcp server (default export) + project.json + Dockerfile # (node target) + nx.json + tsconfig.base.json + package.json +``` + +## Step 6 -- Development Workflow + +### Serve in Development + +```bash +nx serve my-server +``` + +Or use the FrontMCP dev command: + +```bash +nx dev my-server +``` + +### Generate, Build, and Test a New Feature + +A typical workflow for adding a new tool: + +```bash +# 1. Generate the tool scaffold +nx g @frontmcp/nx:tool calculate-tax --project=billing-app + +# 2. Implement the tool logic in apps/billing-app/src/tools/calculate-tax.tool.ts + +# 3. Run tests for the affected app +nx test billing-app + +# 4. Build the server that includes this app +nx build billing-server + +# 5. Or test everything affected by your changes +nx affected -t test +``` + +### Visualize the Project Graph + +```bash +nx graph +``` + +Opens an interactive visualization of project dependencies in your browser. Useful for understanding how apps, libs, and servers relate to each other. + +## Generator Reference + +Complete list of all `@frontmcp/nx` generators from `generators.json`: + +| Generator | Command | Description | +| --------------- | -------------------------------------------------------- | -------------------------------------------------------------------- | +| `workspace` | `nx g @frontmcp/nx:workspace ` | Scaffold a full FrontMCP Nx monorepo with apps/, libs/, and servers/ | +| `app` | `nx g @frontmcp/nx:app ` | Generate a FrontMCP application in apps/ | +| `lib` | `nx g @frontmcp/nx:lib ` | Generate a shared library in libs/ | +| `server` | `nx g @frontmcp/nx:server --apps=` | Generate a deployment shell in servers/ | +| `tool` | `nx g @frontmcp/nx:tool --project=` | Generate a @Tool class | +| `resource` | `nx g @frontmcp/nx:resource --project=` | Generate a @Resource or @ResourceTemplate class | +| `prompt` | `nx g @frontmcp/nx:prompt --project=` | Generate a @Prompt class | +| `skill` | `nx g @frontmcp/nx:skill --project=` | Generate a @Skill class | +| `skill-dir` | `nx g @frontmcp/nx:skill-dir --project=` | Generate a SKILL.md-based skill directory | +| `agent` | `nx g @frontmcp/nx:agent --project=` | Generate an @Agent class | +| `plugin` | `nx g @frontmcp/nx:plugin --project=` | Generate a @Plugin class extending DynamicPlugin | +| `adapter` | `nx g @frontmcp/nx:adapter --project=` | Generate an @Adapter class extending DynamicAdapter | +| `provider` | `nx g @frontmcp/nx:provider --project=` | Generate a @Provider class for dependency injection | +| `flow` | `nx g @frontmcp/nx:flow --project=` | Generate a @Flow class extending FlowBase | +| `job` | `nx g @frontmcp/nx:job --project=` | Generate a @Job class | +| `workflow` | `nx g @frontmcp/nx:workflow --project=` | Generate a @Workflow class | +| `auth-provider` | `nx g @frontmcp/nx:auth-provider --project=` | Generate an @AuthProvider class | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------------------- | ------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------- | +| Primitive generator target | `nx g @frontmcp/nx:tool my-tool --project=my-app` | `nx g @frontmcp/nx:tool my-tool` | All primitive generators require `--project` to specify which app receives the file | +| Test file naming | `my-tool.tool.spec.ts` | `my-tool.tool.test.ts` | FrontMCP enforces `.spec.ts` extension; `.test.ts` files are not picked up by Jest config | +| Affected-only CI testing | `nx affected -t test` | `nx run-many -t test` | `affected` only runs tests for changed projects, saving CI time and compute | +| Server composition | `nx g @frontmcp/nx:server my-server --apps=app-a,app-b` | Manually importing apps in `main.ts` | The server generator wires app composition and deployment config automatically | +| Build before deploy | `nx build my-server` (builds server + all deps) | Building each lib and app individually | Nx resolves the dependency graph and builds in the correct order with caching | + +## Verification Checklist + +### Workspace Setup + +- [ ] `@frontmcp/nx` is listed in `devDependencies` +- [ ] `nx.json` exists at workspace root with valid configuration +- [ ] `apps/`, `libs/`, and `servers/` directories exist + +### Generation + +- [ ] Generated files are placed in the correct directory (`apps//src//`) +- [ ] Barrel exports (`index.ts`) are updated after each generator run +- [ ] `.spec.ts` test file is created alongside each generated class + +### Build and Test + +- [ ] `nx build ` completes without TypeScript errors or warnings +- [ ] `nx test ` passes with 95%+ coverage +- [ ] `nx affected -t test` correctly identifies changed projects + +### Development Workflow + +- [ ] `nx serve ` or `nx dev ` starts the server successfully +- [ ] `nx graph` renders the project dependency graph in the browser + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `Cannot find module '@frontmcp/nx'` | Plugin not installed | Run `yarn add -D @frontmcp/nx` and ensure it appears in `devDependencies` | +| Generator creates files in the wrong directory | Missing or incorrect `--project` flag | Always pass `--project=` for primitive generators; verify the app exists in `apps/` | +| `nx affected` runs nothing despite changes | Base branch not configured or no dependency link | Check `nx.json` for `defaultBase` setting; verify the changed file belongs to a project in the graph | +| Build fails with circular dependency error | Library A imports from Library B and vice versa | Use `nx graph` to visualize the cycle; extract shared code into a new library | +| Cache not working (full rebuild every time) | Missing or misconfigured `cacheableOperations` in `nx.json` | Ensure `build`, `test`, and `lint` are listed in `targetDefaults` with `cache: true` | + +## Reference + +- **Docs:** [Nx Plugin Overview](https://docs.agentfront.dev/frontmcp/nx-plugin/overview) +- **Related skills:** `setup-project`, `setup-sqlite`, `setup-redis` diff --git a/libs/skills/catalog/frontmcp-setup/references/project-structure-nx.md b/libs/skills/catalog/frontmcp-setup/references/project-structure-nx.md new file mode 100644 index 00000000..a35a9197 --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/project-structure-nx.md @@ -0,0 +1,246 @@ +# Nx Monorepo Project Structure + +## When to Use This Skill + +### Must Use + +- Scaffolding a new FrontMCP project with `frontmcp create --nx` or adding FrontMCP to an existing Nx workspace +- Organizing multiple `@App` modules, shared libraries, and `@FrontMcp` server entry points in a monorepo +- Understanding the `apps/`, `libs/`, `servers/` directory hierarchy and Nx dependency rules + +### Recommended + +- Setting up Nx generators to scaffold tools, resources, providers, and other entities within apps +- Configuring multiple servers that compose different combinations of apps (e.g., public gateway and internal admin) +- Leveraging Nx caching, dependency graph, and `run-many` commands for efficient builds and tests + +### Skip When + +- You are building a single standalone project without Nx (see `project-structure-standalone`) +- You need to compose multiple apps within a single server and already have the Nx structure (see `multi-app-composition`) +- You are looking for a specific Nx build or CI workflow (see `nx-workflow`) + +> **Decision:** Use this skill when setting up or organizing a FrontMCP Nx monorepo and you need the canonical directory layout, generator commands, and dependency rules. + +When you scaffold with `frontmcp create --nx` or add FrontMCP to an existing Nx workspace, the recommended layout separates apps, shared libraries, and server entry points: + +```text +my-workspace/ +├── apps/ # @App classes (one app per directory) +│ ├── billing/ +│ │ ├── src/ +│ │ │ ├── billing.app.ts +│ │ │ ├── tools/ +│ │ │ ├── resources/ +│ │ │ └── providers/ +│ │ ├── project.json +│ │ └── tsconfig.json +│ └── crm/ +│ ├── src/ +│ │ ├── crm.app.ts +│ │ ├── tools/ +│ │ └── resources/ +│ ├── project.json +│ └── tsconfig.json +├── libs/ # Shared libraries +│ └── shared-utils/ +│ ├── src/ +│ │ └── index.ts +│ ├── project.json +│ └── tsconfig.json +├── servers/ # @FrontMcp servers composing apps +│ └── gateway/ +│ ├── src/ +│ │ └── main.ts # @FrontMcp default export +│ ├── project.json +│ └── tsconfig.json +├── nx.json +├── tsconfig.base.json +├── CLAUDE.md # AI config (auto-generated) +├── AGENTS.md +├── .mcp.json +└── .cursorrules +``` + +## Directory Roles + +### apps/ -- Application Modules + +Each directory under `apps/` contains a single `@App` class with its tools, resources, prompts, providers, and plugins: + +```typescript +// apps/billing/src/billing.app.ts +import { App } from '@frontmcp/sdk'; +import { CreateInvoiceTool } from './tools/create-invoice.tool'; +import { InvoiceResource } from './resources/invoice.resource'; +import { StripeProvider } from './providers/stripe.provider'; + +@App({ + name: 'billing', + tools: [CreateInvoiceTool], + resources: [InvoiceResource], + providers: [StripeProvider], +}) +export class BillingApp {} +``` + +Apps are self-contained and independently testable. They do not import from other apps -- shared code goes in `libs/`. + +### libs/ -- Shared Libraries + +Shared providers, utilities, types, and common logic live under `libs/`: + +```typescript +// libs/shared-utils/src/index.ts +export { formatCurrency } from './format-currency'; +export { DatabaseProvider } from './database.provider'; +export type { AppConfig } from './app-config.interface'; +``` + +Apps and servers import from libs using Nx path aliases configured in `tsconfig.base.json`: + +```typescript +import { DatabaseProvider } from '@my-workspace/shared-utils'; +``` + +### servers/ -- FrontMcp Entry Points + +A server composes multiple apps into a single `@FrontMcp` entry point: + +```typescript +// servers/gateway/src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; +import { BillingApp } from '@my-workspace/billing'; +import { CrmApp } from '@my-workspace/crm'; + +@FrontMcp({ + info: { name: 'gateway', version: '1.0.0' }, + apps: [BillingApp, CrmApp], +}) +class GatewayServer {} + +export default GatewayServer; +``` + +You can have multiple servers composing different combinations of apps (e.g., a public-facing server and an internal admin server). + +## Nx Generators + +The `@frontmcp/nx` package provides generators for common entity types: + +```bash +# Generate a new app +nx g @frontmcp/nx:app crm + +# Generate entities within an app +nx g @frontmcp/nx:tool lookup-user --project=crm +nx g @frontmcp/nx:resource user-profile --project=crm +nx g @frontmcp/nx:prompt summarize --project=crm +nx g @frontmcp/nx:provider database --project=crm +nx g @frontmcp/nx:plugin logging --project=crm +nx g @frontmcp/nx:agent research --project=crm +nx g @frontmcp/nx:job cleanup --project=crm +nx g @frontmcp/nx:skill my-skill --project=crm +nx g @frontmcp/nx:skill-dir my-skill-dir --project=crm + +# Generate a new server +nx g @frontmcp/nx:server gateway + +# Generate a shared library +nx g @frontmcp/nx:lib shared-utils +``` + +## Build and Test Commands + +```bash +# Build a specific server +nx build gateway + +# Test a specific app +nx test billing + +# Run all tests +nx run-many -t test + +# Build all projects +nx run-many -t build + +# Lint everything +nx run-many -t lint +``` + +Nx caches build and test results. Subsequent runs for unchanged projects are instant. + +## AI Configuration Files + +FrontMCP auto-generates AI configuration files at the workspace root: + +| File | Purpose | +| -------------- | ---------------------------------------- | +| `CLAUDE.md` | Instructions for Claude Code / Claude AI | +| `AGENTS.md` | Instructions for agent-based AI tools | +| `.mcp.json` | MCP server configuration for AI IDEs | +| `.cursorrules` | Rules for Cursor AI editor | + +These files are regenerated when you run generators or modify your workspace structure. They help AI tools understand your project layout and coding conventions. + +## Dependency Graph + +Nx enforces a clear dependency hierarchy: + +```text +servers/ --> apps/ --> libs/ +``` + +- **servers** can import from **apps** and **libs** +- **apps** can import from **libs** only (never from other apps or servers) +- **libs** can import from other **libs** only + +Use `nx graph` to visualize the dependency graph and ensure no circular imports exist. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| App isolation | Apps import from `libs/` only, never from other apps | `apps/billing` imports from `apps/crm` directly | Cross-app imports create circular dependencies; shared code belongs in `libs/` | +| Server composition | `servers/gateway/src/main.ts` imports apps via Nx path aliases | Server file inlines tool classes instead of importing from apps | Servers compose `@App` classes; inlining defeats the monorepo separation | +| Path aliases | `import { BillingApp } from '@my-workspace/billing'` | `import { BillingApp } from '../../apps/billing/src/billing.app'` | Nx path aliases in `tsconfig.base.json` keep imports clean and refactorable | +| Generator usage | `nx g @frontmcp/nx:tool lookup-user --project=crm` | Manually creating tool files without updating barrel exports | Generators handle file creation, spec scaffolding, and barrel export updates | +| AI config files | Let FrontMCP auto-generate `CLAUDE.md`, `AGENTS.md`, `.mcp.json` | Hand-editing auto-generated AI config files | These files are regenerated by generators; manual edits will be overwritten | + +## Verification Checklist + +### Workspace Structure + +- [ ] `nx.json` and `tsconfig.base.json` exist at the workspace root +- [ ] Each app under `apps/` has its own `project.json` and `tsconfig.json` +- [ ] Shared libraries under `libs/` have `project.json` and barrel `index.ts` +- [ ] Server entry points under `servers/` default-export the `@FrontMcp` class + +### Build and Test + +- [ ] `nx build gateway` (or server name) succeeds without errors +- [ ] `nx test billing` (or app name) passes all tests +- [ ] `nx run-many -t test` runs all tests across the workspace +- [ ] `nx graph` shows no circular dependencies between apps + +### Generators + +- [ ] `nx g @frontmcp/nx:app ` creates a valid app scaffold +- [ ] `nx g @frontmcp/nx:tool --project=` creates tool with spec and barrel update +- [ ] `nx g @frontmcp/nx:server ` creates a server entry point + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | +| Import path not resolving | Missing or incorrect path alias in `tsconfig.base.json` | Add the correct `@my-workspace/` path mapping to `tsconfig.base.json` | +| `nx graph` shows circular dependency | App imports from another app instead of a shared lib | Move shared code to `libs/` and import from there in both apps | +| Generator fails with "project not found" | Incorrect `--project` name passed to the generator | Use the project name from `project.json`, not the directory name | +| Nx cache returns stale results | Source files changed but Nx hash did not detect it | Run `nx reset` to clear the cache, then rebuild | +| Server cannot find app export | App barrel `index.ts` does not export the `@App` class | Add the app class to the barrel export in `apps//src/index.ts` | + +## Reference + +- [Nx Plugin Documentation](https://docs.agentfront.dev/frontmcp/nx-plugin/overview) +- Related skills: `project-structure-standalone`, `multi-app-composition`, `nx-workflow`, `setup-project` diff --git a/libs/skills/catalog/frontmcp-setup/references/project-structure-standalone.md b/libs/skills/catalog/frontmcp-setup/references/project-structure-standalone.md new file mode 100644 index 00000000..4ec046b7 --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/project-structure-standalone.md @@ -0,0 +1,212 @@ +# Standalone Project Structure + +## When to Use This Skill + +### Must Use + +- Scaffolding a new FrontMCP project with `frontmcp create` and need to understand the generated layout +- Organizing tools, resources, prompts, and providers in a standalone (non-Nx) project +- Setting up the `main.ts` entry point with the `@FrontMcp` server default export + +### Recommended + +- Adopting consistent `..ts` file naming conventions across the project +- Restructuring an existing standalone project to follow FrontMCP best practices +- Organizing a growing project into feature folders with grouped domain entities + +### Skip When + +- You are working in an Nx monorepo with multiple apps and shared libraries (see `project-structure-nx`) +- You need to compose multiple apps into a single server (see `multi-app-composition`) +- You are creating a specific entity (tool, resource, etc.) and need its decorator API (see `create-tool`, `create-resource`) + +> **Decision:** Use this skill when scaffolding or organizing a standalone FrontMCP project and you need the canonical file layout, naming conventions, and development workflow. + +When you run `frontmcp create`, the CLI scaffolds a standalone project with the following layout: + +```text +my-project/ +├── src/ +│ ├── main.ts # @FrontMcp server entry (default export) +│ ├── my-app.app.ts # @App class +│ ├── tools/ # @Tool classes (*.tool.ts) +│ ├── resources/ # @Resource classes (*.resource.ts) +│ ├── prompts/ # @Prompt classes (*.prompt.ts) +│ ├── agents/ # @Agent classes (*.agent.ts) +│ ├── skills/ # @Skill classes or SKILL.md dirs +│ ├── providers/ # @Provider classes (*.provider.ts) +│ ├── plugins/ # @Plugin classes (*.plugin.ts) +│ └── jobs/ # @Job classes (*.job.ts) +├── e2e/ # E2E tests (*.e2e.spec.ts) +├── skills/ # Catalog skills (from --skills flag) +├── package.json +├── tsconfig.json +└── .env.example +``` + +## File Naming Conventions + +Every entity type uses a consistent `..ts` pattern: + +| Entity | File Pattern | Example | +| -------- | --------------- | ---------------------------- | +| Tool | `*.tool.ts` | `fetch-weather.tool.ts` | +| Resource | `*.resource.ts` | `user-profile.resource.ts` | +| Prompt | `*.prompt.ts` | `summarize.prompt.ts` | +| Agent | `*.agent.ts` | `research.agent.ts` | +| Skill | `*.skill.ts` | `calendar.skill.ts` | +| Provider | `*.provider.ts` | `database.provider.ts` | +| Plugin | `*.plugin.ts` | `logging.plugin.ts` | +| Job | `*.job.ts` | `cleanup.job.ts` | +| Test | `*.spec.ts` | `fetch-weather.tool.spec.ts` | +| E2E Test | `*.e2e.spec.ts` | `api.e2e.spec.ts` | + +**One class per file.** Keep each tool, resource, prompt, etc. in its own file. + +## Entry Point: main.ts + +`main.ts` default-exports the `@FrontMcp` server class. This is the file FrontMCP loads at startup: + +```typescript +import { FrontMcp } from '@frontmcp/sdk'; +import { MyApp } from './my-app.app'; + +@FrontMcp({ + info: { name: 'my-project', version: '1.0.0' }, + apps: [MyApp], +}) +class MyServer {} + +export default MyServer; +``` + +## App Class + +The `@App` class groups tools, resources, prompts, plugins, and providers together: + +```typescript +import { App } from '@frontmcp/sdk'; +import { FetchWeatherTool } from './tools/fetch-weather.tool'; +import { DatabaseProvider } from './providers/database.provider'; + +@App({ + name: 'my-app', + tools: [FetchWeatherTool], + providers: [DatabaseProvider], +}) +export class MyApp {} +``` + +## Development Workflow + +### Start development server + +```bash +frontmcp dev +``` + +Watches for file changes and restarts automatically. + +### Build for production + +```bash +frontmcp build --target node +frontmcp build --target cloudflare +frontmcp build --target vercel +frontmcp build --target lambda +``` + +Valid targets: `cli`, `node`, `sdk`, `browser`, `cloudflare`, `vercel`, `lambda`. The `--target` flag determines the output format and runtime optimizations. + +### Run tests + +```bash +# Unit tests +jest + +# E2E tests +jest --config e2e/jest.config.ts +``` + +## Organizing by Feature + +For larger standalone projects, group related entities into feature folders: + +```text +src/ +├── main.ts +├── my-app.app.ts +├── billing/ +│ ├── create-invoice.tool.ts +│ ├── invoice.resource.ts +│ └── billing.provider.ts +├── users/ +│ ├── lookup-user.tool.ts +│ ├── user-profile.resource.ts +│ └── users.provider.ts +└── plugins/ + └── logging.plugin.ts +``` + +Feature folders work well when your project has multiple related tools and resources that share a domain. + +## Skills Directory + +The top-level `skills/` directory (outside `src/`) holds catalog skills added via the `--skills` flag during `frontmcp create`. Each skill is a folder containing a `SKILL.md` file: + +```text +skills/ +├── create-tool/ +│ └── SKILL.md +└── setup-project/ + └── SKILL.md +``` + +Skills inside `src/skills/` are `@Skill` classes that are part of your application code. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | --------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | +| File naming | `fetch-weather.tool.ts` (kebab-case with type suffix) | `FetchWeather.ts` or `fetchWeatherTool.ts` | The `..ts` convention enables tooling, generators, and consistent imports | +| Entry point | `main.ts` with `export default MyServer` | Named export or no default export in `main.ts` | FrontMCP loads the default export from the entry point at startup | +| One class per file | Each tool, resource, or provider in its own file | Multiple tool classes in a single file | Keeps files focused, simplifies imports, and aligns with generator output | +| Feature folders | Group related entities under `src/billing/`, `src/users/` | Flat structure with dozens of files in `src/tools/` | Feature folders scale better and make domain boundaries visible | +| Test files | `fetch-weather.tool.spec.ts` (`.spec.ts` extension) | `fetch-weather.tool.test.ts` (`.test.ts` extension) | FrontMCP convention requires `.spec.ts`; generators and CI expect this pattern | + +## Verification Checklist + +### Project Structure + +- [ ] `src/main.ts` exists and default-exports the `@FrontMcp` server class +- [ ] At least one `@App` class exists (e.g., `src/my-app.app.ts`) +- [ ] Entity files follow the `..ts` naming convention +- [ ] Test files use the `.spec.ts` extension + +### Development Workflow + +- [ ] `frontmcp dev` starts the development server with file watching +- [ ] `frontmcp build --target node` produces a valid production build +- [ ] Unit tests pass with `jest` +- [ ] E2E tests (if any) are in the `e2e/` directory with `*.e2e.spec.ts` naming + +### Organization + +- [ ] Each entity type has its own directory (`tools/`, `resources/`, etc.) or feature folder +- [ ] Catalog skills (from `--skills` flag) are in the top-level `skills/` directory +- [ ] Application `@Skill` classes are in `src/skills/` + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `frontmcp dev` fails to start | `main.ts` does not default-export the `@FrontMcp` class | Add `export default MyServer` to `main.ts` | +| Tool not discovered at runtime | Tool class not added to the `tools` array in `@App` | Register the tool in the `@App` decorator's `tools` array | +| Tests not found by Jest | Test file uses `.test.ts` instead of `.spec.ts` | Rename to `.spec.ts` to match the FrontMCP test file convention | +| Build target error | Invalid `--target` flag value | Use `node`, `vercel`, `lambda`, or `cloudflare` as the target value | +| Catalog skills not loaded | Skills placed in `src/skills/` instead of top-level `skills/` | Move catalog `SKILL.md` directories to the top-level `skills/` directory | + +## Reference + +- [Quickstart Documentation](https://docs.agentfront.dev/frontmcp/getting-started/quickstart) +- Related skills: `project-structure-nx`, `multi-app-composition`, `setup-project`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-setup/references/setup-project.md b/libs/skills/catalog/frontmcp-setup/references/setup-project.md new file mode 100644 index 00000000..e5fe2101 --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/setup-project.md @@ -0,0 +1,493 @@ +# Scaffold and Configure a New FrontMCP Project + +## When to Use This Skill + +### Must Use + +- Creating a brand-new FrontMCP MCP server project from scratch +- Setting up the `@FrontMcp` root decorator and `@App` structure for the first time +- Choosing and configuring a deployment target (Node, Vercel, Lambda, Cloudflare) + +### Recommended + +- Adding FrontMCP to an existing TypeScript codebase that has no MCP server yet +- Scaffolding a new app inside an Nx monorepo with `@frontmcp/nx` generators +- Setting up the dev-loop (`frontmcp dev`, build, env vars) for a fresh project + +### Skip When + +- The project already has a working `@FrontMcp`-decorated server -- use `create-tool`, `create-resource`, or `create-prompt` to add entries +- You only need to add Redis or SQLite storage to an existing server -- use `setup-redis` or `setup-sqlite` +- You need to configure deployment for an already-scaffolded project -- use `deploy-to-vercel`, `deploy-to-lambda`, or `deploy-to-cloudflare` + +> **Decision:** Use this skill when no FrontMCP server exists yet and you need to scaffold the project structure, dependencies, and entry point from scratch. + +## Step 1 -- Use the CLI Scaffolder (Preferred) + +The `frontmcp` CLI generates a complete project structure. Run it with `npx`: + +```bash +npx frontmcp create +``` + +The CLI will interactively prompt for deployment target, Redis setup, package manager, CI/CD, and skills bundle. To skip prompts, pass flags directly: + +```bash +npx frontmcp create \ + --target \ + --redis \ + --pm \ + --skills \ + --cicd +``` + +All available flags: + +| Flag | Values | Default | Description | +| ---------------------- | ---------------------------------------- | ------------- | --------------------------------------------- | +| `--target` | `node`, `vercel`, `lambda`, `cloudflare` | `node` | Deployment target | +| `--redis` | `docker`, `existing`, `none` | prompted | Redis provisioning strategy | +| `--pm` | `npm`, `yarn`, `pnpm` | prompted | Package manager | +| `--skills` | `recommended`, `minimal`, `full`, `none` | `recommended` | Skills bundle to install | +| `--cicd` / `--no-cicd` | boolean | prompted | Enable GitHub Actions CI/CD | +| `--nx` | boolean | `false` | Scaffold an Nx monorepo instead of standalone | +| `-y, --yes` | boolean | `false` | Accept all defaults non-interactively | + +Add `--yes` to accept all defaults non-interactively: + +```bash +npx frontmcp create my-server --yes +``` + +If the CLI scaffold succeeds, skip to Step 5 (environment variables). The CLI generates the full file tree including the server class, sample tools, Dockerfile, tsconfig, and build scripts. + +## Step 2 -- Manual Setup (if CLI is not available or adding to an existing codebase) + +If the CLI is not available or the project already exists, set up manually. + +### 2a. Initialize the package + +```bash +mkdir -p /src +cd +``` + +Create `package.json`: + +```json +{ + "name": "", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "frontmcp dev", + "build": "frontmcp build", + "start": "node dist/main.js" + }, + "dependencies": { + "frontmcp": "latest", + "@frontmcp/sdk": "latest", + "reflect-metadata": "^0.2.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/node": "^22.0.0" + } +} +``` + +### 2b. Create tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +Critical: `experimentalDecorators` and `emitDecoratorMetadata` must both be `true`. FrontMCP uses TypeScript decorators (`@FrontMcp`, `@App`, `@Tool`, `@Resource`, `@Prompt`, `@Skill`). + +### 2c. Install dependencies + +```bash +yarn install # or npm install / pnpm install +``` + +## Step 3 -- Create the Server Entry Point + +Create `src/main.ts` with the `@FrontMcp` decorator. This is the root of every FrontMCP server. + +The `@FrontMcp` decorator accepts a `FrontMcpMetadata` object with these fields: + +```typescript +import 'reflect-metadata'; +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + // Required fields + info: { + name: '', // string (required) - server name in MCP initialize response + version: '0.1.0', // string (required) - server version + title: 'My Server', // string (optional) - display title + }, + apps: [], // AppType[] (required) - array of @App classes or remote apps + + // Optional fields - include only what you need + // http?: { port: number, host?: string, unixSocket?: string } + // redis?: { provider: 'redis', host: string, port?: number, ... } | { provider: 'vercel-kv', ... } + // sqlite?: { path: string, walMode?: boolean, encryption?: { secret: string } } + // transport?: 'modern' | 'legacy' | 'stateless-api' | 'full' | { protocol?: ProtocolPreset, ... } + // auth?: { mode: 'public' | 'transparent' | 'local' | 'remote', ... } + // logging?: { level?: string, transports?: [...] } + // plugins?: PluginType[] + // providers?: ProviderType[] + // tools?: ToolType[] - shared tools available to all apps + // resources?: ResourceType[] - shared resources available to all apps + // skills?: SkillType[] - shared skills available to all apps + // skillsConfig?: { enabled: boolean, mcpTools?: boolean, cache?: {...}, auth?: 'api-key' | 'bearer' } + // elicitation?: { enabled: boolean } + // pubsub?: { provider: 'redis', host: string, ... } + // pagination?: { ... } + // jobs?: { enabled: boolean, store?: { redis?: {...} } } + // throttle?: { enabled: boolean, global?: {...}, ... } +}) +export default class Server {} +``` + +### Deployment-target-specific configuration + +**Node (default):** No extra transport config needed. The SDK defaults to stdio + Streamable HTTP on port 3000. + +```typescript +import 'reflect-metadata'; +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: '', version: '0.1.0' }, + apps: [], + http: { port: 3000 }, +}) +export default class Server {} +``` + +**Vercel:** Set transport protocol and use Vercel KV for storage: + +```typescript +@FrontMcp({ + info: { name: '', version: '0.1.0' }, + apps: [], + transport: { protocol: 'modern' }, // 'modern' preset enables streamable HTTP + strict sessions + redis: { provider: 'vercel-kv' }, +}) +export default class Server {} +``` + +**Lambda / Cloudflare:** Use the `modern` transport preset. Session storage must be external (Redis). + +```typescript +@FrontMcp({ + info: { name: '', version: '0.1.0' }, + apps: [], + transport: { protocol: 'modern' }, // 'modern' preset enables streamable HTTP + strict sessions + redis: { + provider: 'redis', + host: process.env['REDIS_HOST'] ?? 'localhost', + port: parseInt(process.env['REDIS_PORT'] ?? '6379', 10), + }, +}) +export default class Server {} +``` + +## Step 4 -- Add an App with Tools, Resources, and Prompts + +### 4a. Create a Tool + +Create `src/tools/add.tool.ts`: + +```typescript +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { z } from 'zod'; + +@Tool({ + name: 'add', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + outputSchema: { result: z.number() }, +}) +export default class AddTool extends ToolContext { + async execute(input: { a: number; b: number }) { + return { + result: input.a + input.b, + }; + } +} +``` + +### 4b. Create an App to group entries + +Create `src/apps/calc.app.ts`: + +```typescript +import { App } from '@frontmcp/sdk'; +import AddTool from '../tools/add.tool'; + +@App({ + id: 'calc', // string (optional) - unique identifier + name: 'Calculator', // string (required) - display name + tools: [AddTool], // ToolType[] (optional) + // resources?: ResourceType[] // optional + // prompts?: PromptType[] // optional + // agents?: AgentType[] // optional + // skills?: SkillType[] // optional + // plugins?: PluginType[] // optional + // providers?: ProviderType[] // optional + // adapters?: AdapterType[] // optional + // auth?: AuthOptionsInput // optional - per-app auth override + // standalone?: boolean | 'includeInParent' // optional - default false + // jobs?: JobType[] // optional + // workflows?: WorkflowType[] // optional +}) +export class CalcApp {} +``` + +### 4c. Register the App in the server + +Update `src/main.ts`: + +```typescript +import 'reflect-metadata'; +import { FrontMcp } from '@frontmcp/sdk'; +import { CalcApp } from './apps/calc.app'; + +@FrontMcp({ + info: { name: '', version: '0.1.0' }, + apps: [CalcApp], +}) +export default class Server {} +``` + +### 4d. Additional entry types + +Resources, Prompts, and Skills follow the same decorator pattern: + +```typescript +// Resource - returns MCP ReadResourceResult +import { Resource, ResourceContext } from '@frontmcp/sdk'; + +@Resource({ uri: 'config://app', name: 'App Config', mimeType: 'application/json' }) +export default class AppConfigResource extends ResourceContext { + /* ... */ +} + +// Prompt - returns MCP GetPromptResult +import { Prompt, PromptContext } from '@frontmcp/sdk'; + +@Prompt({ name: 'summarize', description: 'Summarize a document' }) +export default class SummarizePrompt extends PromptContext { + /* ... */ +} + +// Skill - compound capability with tools + instructions +import { Skill, SkillContext } from '@frontmcp/sdk'; + +@Skill({ name: 'data-analysis', description: 'Analyze datasets' }) +export default class DataAnalysisSkill extends SkillContext { + /* ... */ +} +``` + +Register them in the `@App` decorator arrays: `tools`, `resources`, `prompts`, `skills`. + +## Step 5 -- Environment Variables + +Create a `.env` file (never commit this file): + +```env +# Server +PORT=3000 +LOG_LEVEL=verbose + +# Redis (if using Redis storage) +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Auth (if using authentication) +# IDP_PROVIDER_URL=https://your-idp.example.com +# IDP_EXPECTED_AUDIENCE=https://your-idp.example.com +``` + +For Vercel deployments, set these in the Vercel dashboard or `.env.local`. + +Confirm `.env` is in `.gitignore`: + +```bash +echo ".env" >> .gitignore +``` + +## Step 6 -- Run in Development Mode + +```bash +# Start the dev server with hot reload +frontmcp dev +``` + +Or if using package.json scripts: + +```bash +yarn dev +``` + +The server starts in stdio mode by default. To test with HTTP transport, set the PORT: + +```bash +PORT=3000 frontmcp dev +``` + +Test with curl: + +```bash +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' +``` + +Build for production: + +```bash +frontmcp build --target node # Node.js server bundle +frontmcp build --target vercel # Vercel serverless +frontmcp build --target lambda # AWS Lambda +frontmcp build --target cloudflare # Cloudflare Workers +frontmcp build --target cli # CLI with SEA binary +frontmcp build --target cli --js # CLI without SEA +frontmcp build --target sdk # Library (CJS+ESM+types) +``` + +## Step 7 -- Nx Workspace Setup (optional) + +FrontMCP supports Nx monorepos for larger projects with multiple apps and shared libraries. + +### 7a. Scaffold a new Nx workspace + +```bash +npx frontmcp create --nx +``` + +This creates a full Nx workspace with the `@frontmcp/nx` plugin pre-installed. After scaffolding: + +```bash +cd +nx g @frontmcp/nx:app my-app # Add an app +nx g @frontmcp/nx:lib my-lib # Add a library +nx g @frontmcp/nx:tool my-tool # Add a tool to an app +nx g @frontmcp/nx:resource my-res # Add a resource +nx g @frontmcp/nx:prompt my-prompt # Add a prompt +nx g @frontmcp/nx:skill my-skill # Add a skill +nx g @frontmcp/nx:agent my-agent # Add an agent +nx g @frontmcp/nx:provider my-prov # Add a provider +nx g @frontmcp/nx:server my-server # Add a deployment shell +nx dev # Start dev server +``` + +### 7b. Adding FrontMCP to an existing Nx workspace + +Install the Nx plugin: + +```bash +yarn add -D @frontmcp/nx +``` + +Then generate components: + +```bash +nx g @frontmcp/nx:app my-app --directory apps/my-app +nx g @frontmcp/nx:server my-server --directory servers/my-server +``` + +### 7c. Nx project.json example + +If manually configuring, add a `project.json`: + +```json +{ + "name": "", + "root": "apps/", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "options": { + "outputPath": "dist/apps/", + "main": "apps//src/main.ts", + "tsConfig": "apps//tsconfig.json" + } + }, + "serve": { + "executor": "@nx/js:node", + "options": { "buildTarget": ":build" } + }, + "test": { + "executor": "@nx/jest:jest", + "options": { "jestConfig": "apps//jest.config.ts" } + } + } +} +``` + +Run with: `nx serve `. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Server class export | `export default class Server {}` with `@FrontMcp` decorator | Named export or no decorator | The SDK bootstrap expects a default-exported class decorated with `@FrontMcp` | +| Decorator prerequisites | `experimentalDecorators: true` and `emitDecoratorMetadata: true` in tsconfig | Omitting either flag | FrontMCP decorators (`@FrontMcp`, `@App`, `@Tool`) rely on both TypeScript compiler options | +| Reflect metadata import | `import 'reflect-metadata'` at the top of `src/main.ts` | Importing it in individual tool/resource files | The polyfill must load once before any decorator runs; the entry point is the correct place | +| Deployment target storage | External Redis/Vercel KV for serverless targets (Vercel, Lambda, Cloudflare) | In-memory or SQLite storage on serverless | Serverless functions are stateless; persistent storage requires an external provider | +| Environment secrets | `.env` file excluded via `.gitignore`, values read with `process.env` | Hardcoded secrets in source or committed `.env` | Secrets must never be committed to version control | + +## Verification Checklist + +### Configuration + +- [ ] `tsconfig.json` has `experimentalDecorators: true` and `emitDecoratorMetadata: true` +- [ ] `@frontmcp/sdk`, `zod`, and `reflect-metadata` are listed in `package.json` dependencies +- [ ] `package.json` scripts include `dev`, `build`, and `start` commands +- [ ] Deployment target in `@FrontMcp` metadata matches the intended runtime + +### Runtime + +- [ ] `src/main.ts` exists with a `@FrontMcp`-decorated default export +- [ ] `import 'reflect-metadata'` is the first import in `src/main.ts` +- [ ] At least one `@App` class is registered in the `apps` array +- [ ] `frontmcp dev` starts without errors and responds to MCP `initialize` requests +- [ ] `.env` file exists locally and is listed in `.gitignore` + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `TypeError: Reflect.getMetadata is not a function` | `reflect-metadata` is not imported before decorators execute | Add `import 'reflect-metadata'` as the first line in `src/main.ts` | +| Decorators are silently ignored (no tools registered) | `experimentalDecorators` or `emitDecoratorMetadata` is `false` or missing in tsconfig | Set both to `true` in `compilerOptions` and restart the TypeScript compiler | +| `frontmcp dev` exits with "No apps registered" | The `apps` array in `@FrontMcp` metadata is empty or the `@App` class was not imported | Import your `@App` class and add it to the `apps` array | +| Build fails with "Cannot find module '@frontmcp/sdk'" | Dependencies were not installed after scaffolding | Run `yarn install` (or `npm install` / `pnpm install`) in the project root | +| Vercel deploy returns 500 on `/mcp` endpoint | Transport not set to `modern` or storage not configured for Vercel KV | Set `transport: { protocol: 'modern' }` and `redis: { provider: 'vercel-kv' }` in `@FrontMcp` metadata | + +## Reference + +- [Getting Started Quickstart](https://docs.agentfront.dev/frontmcp/getting-started/quickstart) +- Related skills: `setup-redis`, `setup-sqlite`, `nx-workflow`, `deploy-to-vercel`, `deploy-to-node`, `create-tool` diff --git a/libs/skills/catalog/frontmcp-setup/references/setup-redis.md b/libs/skills/catalog/frontmcp-setup/references/setup-redis.md new file mode 100644 index 00000000..457f48df --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/setup-redis.md @@ -0,0 +1,358 @@ +# Configure Redis for Session Storage and Distributed State + +## When to Use This Skill + +### Must Use + +- The server uses Streamable HTTP transport and sessions must survive reconnects +- Multiple server instances run behind a load balancer and need shared state (sessions, rate limits) +- Deploying to serverless (Vercel, Lambda, Cloudflare) where no local filesystem or in-process storage exists + +### Recommended + +- Resource subscriptions with `subscribe: true` are enabled and need pub/sub +- Auth sessions or elicitation state must persist across server restarts +- Distributed rate limiting is configured in the throttle guard + +### Skip When + +- Running a single-instance stdio-only server for local development -- use `setup-sqlite` or in-memory stores +- Only need to configure session TTL and key prefix on an already-provisioned Redis -- use `configure-session` +- Deploying a read-only MCP server with no sessions, subscriptions, or stateful tools + +> **Decision:** Use this skill to provision and connect Redis (Docker, existing instance, or Vercel KV); use `configure-session` to tune session-specific options after Redis is available. + +## Step 1 -- Provision Redis + +### Option A: Docker (local development) + +Create `docker-compose.yml` in the project root: + +```yaml +services: + redis: + image: redis:7-alpine + ports: + - '6379:6379' + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 3s + retries: 3 + +volumes: + redis_data: +``` + +Start the container: + +```bash +docker compose up -d redis +``` + +Verify the connection: + +```bash +docker compose exec redis redis-cli ping +# Expected output: PONG +``` + +### Option B: Vercel KV (Vercel deployments) + +Vercel KV is a managed Redis-compatible store. No Docker or external Redis is needed. + +1. Enable KV in the Vercel dashboard: Project Settings > Storage > Create KV Database. +2. Vercel automatically injects `KV_REST_API_URL` and `KV_REST_API_TOKEN` environment variables. +3. No manual connection string is needed -- the SDK detects Vercel KV environment variables automatically when `provider: 'vercel-kv'` is set. + +### Option C: Existing Redis Instance + +If you already have a Redis server (managed cloud, self-hosted, or shared dev instance), collect: + +- **Host**: the hostname or IP (e.g., `redis.internal`, `10.0.0.5`) +- **Port**: default `6379` +- **Password**: if auth is enabled +- **TLS**: whether the connection requires TLS (most cloud providers require it) +- **DB index**: default `0` + +## Step 2 -- Configure the FrontMCP Server + +The `redis` field in the `@FrontMcp` decorator accepts a `RedisOptionsInput` union type. There are three valid shapes: + +### For Redis (Docker or existing instance) + +Update the `@FrontMcp` decorator in `src/main.ts`: + +```typescript +import 'reflect-metadata'; +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'my-server', version: '0.1.0' }, + apps: [ + /* ... */ + ], + redis: { + provider: 'redis', // 'redis' literal (required) + host: process.env['REDIS_HOST'] ?? 'localhost', // string (required) + port: parseInt(process.env['REDIS_PORT'] ?? '6379', 10), // number (default: 6379) + password: process.env['REDIS_PASSWORD'], // string (optional) + db: 0, // number (default: 0) + tls: false, // boolean (default: false) + keyPrefix: 'mcp:', // string (default: 'mcp:') + defaultTtlMs: 3600000, // number (default: 3600000 = 1 hour) + }, +}) +export default class Server {} +``` + +For TLS connections (cloud-hosted Redis): + +```typescript +redis: { + provider: 'redis', + host: process.env['REDIS_HOST'] ?? 'redis.example.com', + port: parseInt(process.env['REDIS_PORT'] ?? '6380', 10), + password: process.env['REDIS_PASSWORD'], + tls: true, + keyPrefix: 'mcp:', +}, +``` + +Legacy format (without `provider` field) is also supported and auto-transforms to `provider: 'redis'`: + +```typescript +redis: { + host: 'localhost', + port: 6379, +}, +``` + +### For Vercel KV + +```typescript +import 'reflect-metadata'; +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'my-server', version: '0.1.0' }, + apps: [ + /* ... */ + ], + redis: { + provider: 'vercel-kv', // 'vercel-kv' literal (required) + // url and token are auto-detected from KV_REST_API_URL / KV_REST_API_TOKEN env vars + keyPrefix: 'mcp:', // string (default: 'mcp:') + defaultTtlMs: 3600000, // number (default: 3600000) + }, +}) +export default class Server {} +``` + +If you need to pass explicit credentials (e.g., in testing or non-Vercel environments): + +```typescript +redis: { + provider: 'vercel-kv', + url: process.env['KV_REST_API_URL'], // string (optional, default from env) + token: process.env['KV_REST_API_TOKEN'], // string (optional, default from env) + keyPrefix: 'mcp:', +}, +``` + +## Step 3 -- Session Store Factory (Advanced) + +The SDK creates the session store automatically from the `redis` config. For advanced scenarios where you need direct access to the session store factory: + +```typescript +import { createSessionStore } from '@frontmcp/sdk'; + +// Redis provider +const sessionStore = await createSessionStore({ + provider: 'redis', + host: 'localhost', + port: 6379, + keyPrefix: 'mcp:', +}); + +// Vercel KV provider (requires await for pre-connection) +const sessionStore = await createSessionStore({ + provider: 'vercel-kv', + keyPrefix: 'mcp:', +}); +``` + +The `createSessionStore()` function signature: + +```typescript +async function createSessionStore( + options: RedisOptions, // RedisProviderOptions | VercelKvProviderOptions | legacy format + logger?: FrontMcpLogger, +): Promise; +``` + +The factory function handles: + +- Lazy-loading `ioredis` or `@vercel/kv` to avoid bundling unused dependencies +- Automatic key prefix namespacing (appends `session:` to the base prefix) +- Pre-connection for Vercel KV (the `await` is required) + +There is also a synchronous variant for Redis-only (does not support Vercel KV): + +```typescript +import { createSessionStoreSync } from '@frontmcp/sdk'; + +const sessionStore = createSessionStoreSync({ + provider: 'redis', + host: 'localhost', + port: 6379, +}); +``` + +## Step 4 -- Pub/Sub for Resource Subscriptions + +If your server exposes resources with `subscribe: true`, you need pub/sub. Pub/sub requires a real Redis instance -- Vercel KV does not support pub/sub operations. + +For a hybrid setup (Vercel KV for sessions, Redis for pub/sub): + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '0.1.0' }, + apps: [ + /* ... */ + ], + redis: { + provider: 'vercel-kv', + keyPrefix: 'mcp:', + }, + pubsub: { + provider: 'redis', + host: process.env['REDIS_PUBSUB_HOST'] ?? 'localhost', + port: parseInt(process.env['REDIS_PUBSUB_PORT'] ?? '6379', 10), + password: process.env['REDIS_PUBSUB_PASSWORD'], + }, +}) +export default class Server {} +``` + +The `pubsub` field accepts the same shape as `redis` but only supports `provider: 'redis'` or the legacy format (no Vercel KV support for pub/sub). + +If only a Redis provider is configured (no Vercel KV), the SDK falls back to using the `redis` config for both sessions and pub/sub automatically. A separate `pubsub` config is only needed when using Vercel KV for sessions. + +## Step 5 -- Transport Persistence Auto-Configuration + +When `redis` is configured, the SDK automatically enables transport session persistence. The auto-configuration logic works as follows: + +1. If `redis` is set and `transport.persistence` is not configured, persistence is auto-enabled with the global redis config. +2. If `transport.persistence` is explicitly `false`, persistence is disabled. +3. If `transport.persistence.redis` is explicitly set, that config is used instead. +4. If `transport.persistence` is an object without `redis`, the global redis config is injected. + +This means you do not need to configure `transport.persistence` separately when using the top-level `redis` field. + +## Step 6 -- Environment Variables + +Add to your `.env` file: + +```env +# Redis connection +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Vercel KV (auto-injected on Vercel, manual for local testing) +# KV_REST_API_URL=https://your-kv.kv.vercel-storage.com +# KV_REST_API_TOKEN=your-token + +# Pub/Sub (only if different from main Redis) +# REDIS_PUBSUB_HOST=localhost +# REDIS_PUBSUB_PORT=6379 +``` + +Confirm `.env` is in `.gitignore`. Never commit credentials. + +## Step 7 -- Test the Connection + +### Verify from the application + +Start the server and check the logs for successful Redis connection: + +```bash +frontmcp dev +``` + +Look for log lines like: + +```text +[SessionStoreFactory] Creating Redis session store +[RedisStorageAdapter] Connected to Redis at localhost:6379 +``` + +### Verify from the command line + +```bash +# Docker +docker compose exec redis redis-cli -h localhost -p 6379 ping + +# Existing instance +redis-cli -h -p -a ping +``` + +### Verify keys are being written + +After making at least one MCP request through HTTP transport: + +```bash +redis-cli -h localhost -p 6379 keys "mcp:*" +``` + +You should see session keys like `mcp:session:`. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ---------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Redis provider field | `redis: { provider: 'redis', host: '...', port: 6379 }` | `redis: { host: '...', port: 6379 }` without `provider` | Both forms are type-safe (the SDK's `RedisOptions` union accepts both shapes), but explicit `provider: 'redis'` improves clarity and intent | +| Environment variables | `host: process.env['REDIS_HOST'] ?? 'localhost'` | Hardcoding `host: 'redis.internal'` in source | Hardcoded values break across environments (dev, staging, prod); always read from env with a sensible fallback | +| Vercel KV credentials | Let Vercel auto-inject `KV_REST_API_URL` and `KV_REST_API_TOKEN` | Manually setting KV tokens in the `redis` config object | Auto-injection is safer and ensures tokens rotate correctly; manual values risk stale or committed secrets | +| Docker persistence | `command: redis-server --appendonly yes` in docker-compose | Running Redis without `--appendonly` in development | Without AOF persistence, data is lost on container restart; `--appendonly yes` preserves data across restarts | +| Pub/sub with Vercel KV | Separate `pubsub: { provider: 'redis', ... }` alongside `redis: { provider: 'vercel-kv' }` | Expecting Vercel KV to handle pub/sub | Vercel KV does not support pub/sub; a real Redis instance is required for resource subscriptions | + +## Verification Checklist + +### Provisioning + +- [ ] Redis is reachable (`redis-cli ping` returns `PONG`, or Vercel KV dashboard shows the store is active) +- [ ] Docker container is running and healthy (`docker compose ps` shows `healthy` status) +- [ ] For existing instances: host, port, password, and TLS settings are correct + +### Configuration + +- [ ] The `redis` block is present in the `@FrontMcp` decorator with a valid `provider` field (`'redis'` or `'vercel-kv'`) +- [ ] Environment variables (`REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`) are set in `.env` +- [ ] `.env` file is listed in `.gitignore` -- credentials are never committed +- [ ] For Vercel KV: `provider: 'vercel-kv'` is set and KV environment variables are present + +### Runtime + +- [ ] The server starts without Redis connection errors in the logs +- [ ] `redis-cli keys "mcp:*"` shows keys after at least one MCP request through HTTP transport +- [ ] For pub/sub: a separate `pubsub` config pointing to real Redis is provided when using Vercel KV for sessions + +## Troubleshooting + +| Problem | Cause | Solution | +| ------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `ECONNREFUSED 127.0.0.1:6379` | Redis is not running or Docker container is stopped | Start the container with `docker compose up -d redis` or check the Redis service status | +| `NOAUTH Authentication required` | Password is set on Redis but not provided in config | Add `password` to the `redis` config or set `REDIS_PASSWORD` environment variable | +| `ERR max number of clients reached` | Too many open connections from the application | Set `maxRetriesPerRequest` or use connection pooling; check for connection leaks | +| Vercel KV `401 Unauthorized` | Missing or invalid KV tokens in the environment | Verify `KV_REST_API_URL` and `KV_REST_API_TOKEN` in the Vercel dashboard and redeploy | +| Sessions lost after container restart | Redis running without append-only persistence | Add `--appendonly yes` to the Redis command in docker-compose or use a managed Redis with persistence enabled | + +## Reference + +- [Redis Setup Docs](https://docs.agentfront.dev/frontmcp/deployment/redis-setup) +- Related skills: `configure-session`, `setup-project`, `setup-sqlite`, `configure-transport` diff --git a/libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md b/libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md new file mode 100644 index 00000000..a6f37c71 --- /dev/null +++ b/libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md @@ -0,0 +1,347 @@ +# Configure SQLite for Local and Single-Instance Deployments + +## When to Use This Skill + +### Must Use + +- Your FrontMCP server runs as a single instance with local persistent storage (CLI tools, unix-socket daemons) +- You need session or key-value storage for local development without running external services +- Your deployment target is a single-process Node.js server on a machine with a local filesystem + +### Recommended + +- You are building a CLI tool or local-only MCP server that will never be horizontally scaled +- Local development when running a Redis container is unnecessary overhead +- Projects that store session data, credentials, or counters on a single host + +### Skip When + +- Deploying to serverless (Vercel, Lambda, Cloudflare) where there is no persistent local filesystem -- use `setup-redis` instead +- Running multiple server instances behind a load balancer -- use `setup-redis` instead +- You need pub/sub for resource subscriptions or real-time event distribution -- use `setup-redis` instead + +> **Decision:** Use SQLite for single-instance local storage; switch to `setup-redis` for multi-instance or serverless deployments. + +## Step 1 -- Install the Native Dependency + +The `@frontmcp/storage-sqlite` package depends on `better-sqlite3`, which compiles a native C module during installation. Build tools must be available on the system. + +```bash +yarn add @frontmcp/storage-sqlite better-sqlite3 +yarn add -D @types/better-sqlite3 +``` + +If the install fails with compilation errors: + +- **macOS**: Install Xcode Command Line Tools: `xcode-select --install` +- **Linux (Debian/Ubuntu)**: `sudo apt-get install build-essential python3` +- **Linux (Alpine)**: `apk add build-base python3` +- **Windows**: Install Visual Studio Build Tools with the "Desktop development with C++" workload + +Verify the native module loads: + +```bash +node -e "require('better-sqlite3')" +``` + +No output means success. An error means the native bindings did not compile correctly. + +## Step 2 -- Configure the FrontMCP Server + +The `sqlite` field in the `@FrontMcp` decorator accepts a `SqliteOptionsInput` object with the following shape: + +```typescript +interface SqliteOptionsInput { + /** Path to the .sqlite database file (required) */ + path: string; + + /** Enable WAL mode for better read concurrency (default: true) */ + walMode?: boolean; + + /** Encryption config for at-rest encryption of values (optional) */ + encryption?: { + /** Secret key material for AES-256-GCM encryption via HKDF-SHA256 */ + secret: string; + }; + + /** Interval in ms for purging expired keys (default: 60000) */ + ttlCleanupIntervalMs?: number; +} +``` + +### Basic SQLite setup + +Update the `@FrontMcp` decorator in `src/main.ts`: + +```typescript +import 'reflect-metadata'; +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'my-cli-server', version: '0.1.0' }, + apps: [ + /* ... */ + ], + sqlite: { + path: '~/.frontmcp/data/sessions.sqlite', + walMode: true, + }, +}) +export default class Server {} +``` + +Configuration reference: + +| Option | Type | Default | Description | +| ---------------------- | -------------------- | ----------- | --------------------------------------------------- | +| `path` | `string` | (required) | Absolute or `~`-prefixed path to the `.sqlite` file | +| `walMode` | `boolean` | `true` | Enable WAL mode for better read concurrency | +| `encryption` | `{ secret: string }` | `undefined` | AES-256-GCM encryption for values at rest | +| `ttlCleanupIntervalMs` | `number` | `60000` | Interval for purging expired keys (milliseconds) | + +### With at-rest encryption + +If the database stores sensitive session data (tokens, credentials), enable encryption: + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '0.1.0' }, + apps: [ + /* ... */ + ], + sqlite: { + path: '~/.frontmcp/data/sessions.sqlite', + walMode: true, + encryption: { + secret: process.env['SQLITE_ENCRYPTION_SECRET']!, + }, + }, +}) +export default class Server {} +``` + +The encryption uses HKDF-SHA256 for key derivation and AES-256-GCM for value encryption. The secret should be at least 32 characters. Store it in environment variables, never in source code. + +### For a unix-socket daemon + +```typescript +@FrontMcp({ + info: { name: 'frontmcp-daemon', version: '0.1.0' }, + apps: [ + /* ... */ + ], + sqlite: { + path: '/var/lib/frontmcp/daemon.sqlite', + walMode: true, + }, + transport: { + protocol: 'modern', // 'modern' preset enables streamable HTTP + strict sessions + }, + http: { + unixSocket: '/tmp/frontmcp.sock', + }, +}) +export default class Server {} +``` + +### With custom TTL cleanup interval + +For high-throughput servers with many short-lived sessions, reduce the cleanup interval: + +```typescript +sqlite: { + path: '~/.frontmcp/data/sessions.sqlite', + walMode: true, + ttlCleanupIntervalMs: 15000, // purge expired keys every 15 seconds +}, +``` + +## Step 3 -- WAL Mode Configuration + +WAL (Write-Ahead Logging) mode is enabled by default (`walMode: true`) and is strongly recommended. It provides: + +- Concurrent readers do not block writers +- Writers do not block readers +- Better performance for read-heavy workloads (typical for MCP session lookups) + +WAL mode creates two additional files alongside the database: + +```text +sessions.sqlite # main database +sessions.sqlite-wal # write-ahead log +sessions.sqlite-shm # shared memory index +``` + +All three files must be on the same filesystem. Do not place the database on a network mount (NFS, SMB) when using WAL mode. + +To disable WAL mode (only if you have a specific reason, such as a filesystem that does not support shared memory): + +```typescript +sqlite: { + path: '~/.frontmcp/data/sessions.sqlite', + walMode: false, +}, +``` + +## Step 4 -- Session Store Factory (Advanced) + +The SDK creates the SQLite session store automatically from the `sqlite` config in the `@FrontMcp` decorator. For advanced scenarios where you need direct access to the factory function: + +```typescript +import { createSqliteSessionStore } from '@frontmcp/sdk'; + +const sessionStore = createSqliteSessionStore({ + path: '~/.frontmcp/data/sessions.sqlite', + walMode: true, + encryption: { secret: process.env['SQLITE_ENCRYPTION_SECRET']! }, +}); +``` + +The `createSqliteSessionStore()` function signature: + +```typescript +function createSqliteSessionStore(options: SqliteOptionsInput, logger?: FrontMcpLogger): SessionStore; +``` + +The factory function: + +- Lazy-loads `@frontmcp/storage-sqlite` to avoid bundling native modules when not used +- Handles WAL mode pragma configuration internally +- Sets up the TTL cleanup interval for automatic key expiration +- Creates the database file and parent directories if they do not exist +- Returns synchronously (unlike the Redis `createSessionStore` which is async) + +## Step 5 -- Environment Variables + +Add to your `.env` file: + +```env +# SQLite storage +SQLITE_DB_PATH=~/.frontmcp/data/sessions.sqlite + +# Encryption (optional, at least 32 characters) +# SQLITE_ENCRYPTION_SECRET=your-secret-key-at-least-32-chars-long +``` + +Confirm `.env` is in `.gitignore`. Never commit credentials. + +## Step 6 -- Verify the Setup + +Start the server: + +```bash +frontmcp dev +``` + +Check the logs for SQLite initialization: + +```text +[SessionStoreFactory] Creating SQLite session store +``` + +Verify the database file was created: + +```bash +ls -la ~/.frontmcp/data/sessions.sqlite +``` + +If WAL mode is enabled, you should also see: + +```bash +ls -la ~/.frontmcp/data/sessions.sqlite-wal +ls -la ~/.frontmcp/data/sessions.sqlite-shm +``` + +Inspect the database contents (after at least one session is created): + +```bash +sqlite3 ~/.frontmcp/data/sessions.sqlite ".tables" +sqlite3 ~/.frontmcp/data/sessions.sqlite "SELECT key FROM kv_store LIMIT 5;" +``` + +## Migrating from SQLite to Redis + +When your project outgrows single-instance deployment, migrate to Redis: + +1. Run the `setup-redis` skill to configure Redis. +2. Replace the `sqlite` block with a `redis` block in the `@FrontMcp` decorator. +3. Remove `@frontmcp/storage-sqlite` and `better-sqlite3` from dependencies. +4. Active sessions will not transfer -- users will need to re-authenticate. + +The change in `src/main.ts`: + +```typescript +// Before (SQLite) +@FrontMcp({ + info: { name: 'my-server', version: '0.1.0' }, + apps: [/* ... */], + sqlite: { + path: '~/.frontmcp/data/sessions.sqlite', + walMode: true, + }, +}) + +// After (Redis) +@FrontMcp({ + info: { name: 'my-server', version: '0.1.0' }, + apps: [/* ... */], + redis: { + provider: 'redis', + host: 'localhost', + port: 6379, + keyPrefix: 'mcp:', + }, +}) +``` + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------------------ | -------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| Database path | `path: '~/.frontmcp/data/sessions.sqlite'` | `path: './sessions.sqlite'` | Tilde-prefixed or absolute paths are stable across working directories; relative paths break when CWD changes | +| Encryption secret source | `secret: process.env['SQLITE_ENCRYPTION_SECRET']!` | `secret: 'hardcoded-secret-value'` | Secrets must come from environment variables, never committed to source code | +| WAL mode default | `walMode: true` (or omit, defaults to `true`) | `walMode: false` without a specific reason | WAL provides better read concurrency with no downside on local filesystems | +| Native dependency installation | `yarn add @frontmcp/storage-sqlite better-sqlite3` | `yarn add better-sqlite3` alone | Both packages are required; the storage package wraps the native bindings with FrontMCP session store logic | +| TTL cleanup interval | `ttlCleanupIntervalMs: 60000` (default) | `ttlCleanupIntervalMs: 500` | Overly aggressive cleanup wastes CPU; the default 60s is appropriate for most workloads | + +## Verification Checklist + +### Dependencies + +- [ ] `@frontmcp/storage-sqlite` and `better-sqlite3` are in `dependencies` +- [ ] `@types/better-sqlite3` is in `devDependencies` +- [ ] `node -e "require('better-sqlite3')"` runs without errors + +### Configuration + +- [ ] The `sqlite` block is present in the `@FrontMcp` decorator config with a valid `path` string +- [ ] The database path parent directory exists and is writable +- [ ] WAL mode is enabled (default) unless there is a specific filesystem limitation + +### Environment and Security + +- [ ] Environment variables are in `.env` and `.env` is gitignored +- [ ] If encryption is enabled: `SQLITE_ENCRYPTION_SECRET` is set and is at least 32 characters +- [ ] No secrets are hardcoded in source files + +### Runtime + +- [ ] The server starts without SQLite errors (`frontmcp dev`) +- [ ] The database file is created at the configured path +- [ ] If WAL mode is enabled: `.sqlite-wal` and `.sqlite-shm` files appear alongside the database + +## Troubleshooting + +| Problem | Cause | Solution | +| --------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `Cannot find module 'better-sqlite3'` | Native module not installed | Run `yarn add @frontmcp/storage-sqlite better-sqlite3` | +| `Could not locate the bindings file` | Native compilation failed | Ensure build tools are installed (Xcode CLI on macOS, `build-essential` on Linux), delete `node_modules` and reinstall | +| `SQLITE_BUSY` errors | Multiple processes accessing the same database file | Enable WAL mode (`walMode: true`) or ensure only one process writes to the database | +| `SQLITE_READONLY` | Insufficient file permissions | Check write permissions on the database file and its parent directory | +| WAL errors on network mount | WAL mode requires a local filesystem with shared-memory support | Move the database to a local disk or set `walMode: false` | +| Encrypted data unreadable after restart | Encryption secret changed or missing | The secret must be identical across restarts; if the original secret is lost, delete the database and let it be recreated | + +## Reference + +- **Docs:** [SQLite Setup Guide](https://docs.agentfront.dev/frontmcp/deployment/sqlite-setup) +- **Related skills:** `setup-redis`, `setup-project`, `nx-workflow` diff --git a/libs/skills/catalog/frontmcp-testing/SKILL.md b/libs/skills/catalog/frontmcp-testing/SKILL.md new file mode 100644 index 00000000..11d7c0d2 --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: frontmcp-testing +description: "Domain router for testing MCP servers \u2014 unit tests, E2E tests, coverage, and quality assurance. Use when starting any testing task for a FrontMCP application." +tags: [router, testing, jest, e2e, coverage, quality, guide] +priority: 10 +visibility: both +license: Apache-2.0 +metadata: + docs: https://docs.agentfront.dev/frontmcp/testing/overview +--- + +# FrontMCP Testing Router + +Entry point for testing FrontMCP applications. This skill helps you navigate testing strategies across component types and find the right patterns for unit, integration, and E2E tests. + +## When to Use This Skill + +### Must Use + +- Setting up testing infrastructure for a new FrontMCP project +- Deciding how to test a specific component type (tool, resource, prompt, agent) +- Planning a testing strategy that covers unit, E2E, and coverage requirements + +### Recommended + +- Looking up testing patterns for a component type you haven't tested before +- Understanding the relationship between unit tests, E2E tests, and coverage thresholds +- Troubleshooting test failures or coverage gaps + +### Skip When + +- You need detailed Jest configuration and test harness setup (go directly to `setup-testing`) +- You need to build components, not test them (see `frontmcp-development`) +- You need to deploy, not test (see `frontmcp-deployment`) + +> **Decision:** Use this skill for testing strategy and routing. Use `setup-testing` for hands-on Jest configuration and test writing. + +## Scenario Routing Table + +| Scenario | Skill / Section | Description | +| --------------------------------------- | ---------------------------------- | --------------------------------------------------------- | +| Set up Jest, coverage, and test harness | `setup-testing` | Full Jest config, test utilities, and coverage thresholds | +| Write unit tests for a tool | `setup-testing` (Unit Testing) | Mock DI, validate input/output, test error paths | +| Write unit tests for a resource | `setup-testing` (Unit Testing) | Test URI resolution, template params, read results | +| Write unit tests for a prompt | `setup-testing` (Unit Testing) | Test argument handling, message generation | +| Write E2E protocol-level tests | `setup-testing` (E2E Testing) | Real MCP client/server, full protocol flow | +| Test authenticated endpoints | `setup-testing` + `configure-auth` | E2E with OAuth tokens, session validation | +| Test deployment builds | `setup-testing` + `deploy-to-*` | Smoke tests against built output | + +## Testing Strategy by Component Type + +| Component | Unit Test Focus | E2E Test Focus | Key Assertions | +| --------- | -------------------------------------------------------- | ---------------------------------- | ---------------------------------------------- | +| Tool | Input validation, execute logic, error paths, DI mocking | `tools/call` via MCP client | Output matches schema, errors return MCP codes | +| Resource | URI resolution, read content, template param handling | `resources/read` via MCP client | Content type correct, URI patterns resolve | +| Prompt | Argument validation, message generation, multi-turn | `prompts/get` via MCP client | Messages match expected structure | +| Agent | LLM config, tool selection, handoff logic | Agent loop via MCP client | Tools called in order, result synthesized | +| Provider | Lifecycle hooks, factory output, singleton behavior | Indirectly via tool/resource tests | Instance reuse, cleanup on scope disposal | +| Job | Progress tracking, retry logic, attempt counting | Job execution via test harness | Progress events emitted, retries respected | + +## Cross-Cutting Testing Patterns + +| Pattern | Rule | +| ------------------ | ------------------------------------------------------------------------------------- | +| File naming | Always `.spec.ts` (not `.test.ts`); E2E uses `.e2e.spec.ts` | +| Coverage threshold | 95%+ across statements, branches, functions, lines | +| Test descriptions | Plain English, no prefixes like "PT-001"; describe behavior not implementation | +| Mocking | Mock providers via DI token replacement, never mock the framework | +| Error testing | Assert `instanceof` specific error class AND MCP error code | +| Async | Always `await` async operations; use `expect(...).rejects.toThrow()` for async errors | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ------------------ | ------------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------- | +| Test file location | `fetch-weather.tool.spec.ts` next to source | `__tests__/fetch-weather.test.ts` | Co-location with `.spec.ts` extension matches FrontMCP conventions | +| DI mocking | Replace token with mock via `scope.register(TOKEN, mockImpl)` | `jest.mock('../provider')` module mock | DI mocking is cleaner, type-safe, and tests the real integration path | +| Error assertions | `expect(err).toBeInstanceOf(ResourceNotFoundError)` | `expect(err.message).toContain('not found')` | Class checks are stable; message strings are fragile | +| E2E transport | Use `@frontmcp/testing` MCP client with real server | HTTP requests with `fetch` | The test client handles protocol details (session, framing) | +| Coverage gaps | Investigate uncovered branches, add targeted tests | Add `istanbul ignore` comments | Coverage gaps often hide real bugs; ignoring them defeats the purpose | + +## Verification Checklist + +### Infrastructure + +- [ ] Jest configured with `@frontmcp/testing` preset +- [ ] Coverage thresholds set to 95% in jest.config +- [ ] Test files use `.spec.ts` extension throughout + +### Unit Tests + +- [ ] Each tool has unit tests covering happy path, validation errors, and DI failures +- [ ] Each resource has unit tests covering URI resolution and read content +- [ ] Provider lifecycle (init, dispose) tested where applicable + +### E2E Tests + +- [ ] At least one E2E test exercises full MCP protocol flow (connect, list, call, disconnect) +- [ ] Authenticated E2E tests use proper test tokens (not mocked auth) +- [ ] E2E tests clean up state after execution + +### CI Integration + +- [ ] Tests run in CI pipeline on every PR +- [ ] Coverage report published and enforced +- [ ] Failing tests block merge + +## Troubleshooting + +| Problem | Cause | Solution | +| ---------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Jest not finding test files | Wrong file extension (`.test.ts` instead of `.spec.ts`) | Rename to `.spec.ts`; check `testMatch` in jest.config | +| Coverage below 95% | Untested error paths or conditional branches | Run `jest --coverage` and inspect uncovered lines in the report | +| E2E test timeout | Server startup too slow or port conflict | Increase Jest timeout; use random port allocation | +| DI resolution fails in tests | Provider not registered in test scope | Register mock providers before creating the test context | +| Istanbul shows 0% on async methods | TypeScript source-map mismatch with Istanbul | Known issue with some TS compilation settings; verify coverage with actual test output | + +## Reference + +- [Testing Documentation](https://docs.agentfront.dev/frontmcp/testing/overview) +- Related skills: `setup-testing`, `create-tool`, `create-resource`, `create-prompt`, `configure-auth` diff --git a/libs/skills/catalog/frontmcp-testing/references/setup-testing.md b/libs/skills/catalog/frontmcp-testing/references/setup-testing.md new file mode 100644 index 00000000..2138b49d --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/setup-testing.md @@ -0,0 +1,550 @@ +# Set Up Testing for FrontMCP Applications + +This skill covers testing FrontMCP applications at three levels: unit tests for individual tools/resources/prompts, E2E tests exercising the full MCP protocol, and manual testing with `frontmcp dev`. + +## When to Use This Skill + +### Must Use + +- Writing the first unit test for a new tool, resource, or prompt class +- Setting up Jest configuration and coverage thresholds for a FrontMCP library +- Creating E2E tests that exercise the full MCP protocol via `McpTestClient` + +### Recommended + +- Adding coverage enforcement to CI for an existing library that lacks thresholds +- Writing authenticated E2E tests with `TestTokenFactory` and `MockOAuthServer` +- Migrating existing `.test.ts` files to the required `.spec.ts` naming convention + +### Skip When + +- Building a new tool class from scratch (see `create-tool`) +- Creating resources or prompts before you have anything to test (see `create-resource`, `create-prompt`) +- Debugging deployment issues unrelated to test configuration (see `deploy-to-node`, `deploy-to-vercel`) + +> **Decision:** Use this skill when you need to configure, write, or run Jest tests for FrontMCP tools, resources, or prompts. + +## Testing Standards + +FrontMCP requires: + +- **95%+ coverage** across statements, branches, functions, and lines +- **All tests passing** with zero failures +- **File naming**: all test files use `.spec.ts` extension (NOT `.test.ts`) +- **E2E test naming**: use `.e2e.spec.ts` suffix +- **Performance test naming**: use `.perf.spec.ts` suffix +- **Playwright test naming**: use `.pw.spec.ts` suffix + +## Unit Testing with Jest + +### Test File Structure + +Place test files next to the source file or in a `__tests__` directory: + +```text +src/ + tools/ + my-tool.ts + __tests__/ + my-tool.spec.ts # Unit tests +``` + +### Testing a Tool + +Tools extend `ToolContext` and implement `execute()`. Test the execute method by providing mock inputs and verifying outputs match the MCP `CallToolResult` shape. + +```typescript +// my-tool.spec.ts +import { MyTool } from '../my-tool'; + +describe('MyTool', () => { + let tool: MyTool; + + beforeEach(() => { + tool = new MyTool(); + }); + + it('should return formatted result for valid input', async () => { + // Create a mock execution context + const mockContext = { + scope: { + get: jest.fn(), + tryGet: jest.fn(), + }, + fail: jest.fn(), + mark: jest.fn(), + fetch: jest.fn(), + }; + + // Bind mock context + Object.assign(tool, mockContext); + + const result = await tool.execute({ query: 'test input' }); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('test input') }], + }); + }); + + it('should handle missing optional parameters', async () => { + const mockContext = { + scope: { get: jest.fn(), tryGet: jest.fn() }, + fail: jest.fn(), + mark: jest.fn(), + fetch: jest.fn(), + }; + Object.assign(tool, mockContext); + + const result = await tool.execute({ query: 'test' }); + + expect(result.content).toBeDefined(); + expect(result.content.length).toBeGreaterThan(0); + }); + + it('should throw for invalid input', async () => { + const mockContext = { + scope: { get: jest.fn(), tryGet: jest.fn() }, + fail: jest.fn(), + }; + Object.assign(tool, mockContext); + + await expect(tool.execute({ query: '' })).rejects.toThrow(); + }); +}); +``` + +### Testing a Resource + +Resources extend `ResourceContext` and implement `read()`. Verify the output matches the MCP `ReadResourceResult` shape. + +```typescript +// my-resource.spec.ts +import { MyResource } from '../my-resource'; + +describe('MyResource', () => { + it('should return resource contents', async () => { + const resource = new MyResource(); + const result = await resource.read({ id: '123' }); + + expect(result).toEqual({ + contents: [ + { + uri: expect.stringMatching(/^resource:\/\//), + mimeType: 'application/json', + text: expect.any(String), + }, + ], + }); + }); +}); +``` + +### Testing a Prompt + +Prompts extend `PromptContext` and implement `execute()`. Verify the output matches the MCP `GetPromptResult` shape. + +```typescript +// my-prompt.spec.ts +import { MyPrompt } from '../my-prompt'; + +describe('MyPrompt', () => { + it('should return a valid GetPromptResult', async () => { + const prompt = new MyPrompt(); + const result = await prompt.execute({ topic: 'testing' }); + + expect(result).toEqual({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: expect.objectContaining({ type: 'text' }), + }), + ]), + }); + }); +}); +``` + +### Testing Error Classes + +Always verify error classes with `instanceof` checks and error codes: + +```typescript +import { ResourceNotFoundError, MCP_ERROR_CODES } from '@frontmcp/sdk'; + +describe('ResourceNotFoundError', () => { + it('should be instanceof ResourceNotFoundError', () => { + const error = new ResourceNotFoundError('test://resource'); + expect(error).toBeInstanceOf(ResourceNotFoundError); + expect(error.mcpErrorCode).toBe(MCP_ERROR_CODES.RESOURCE_NOT_FOUND); + }); + + it('should produce correct JSON-RPC error', () => { + const error = new ResourceNotFoundError('test://resource'); + const rpc = error.toJsonRpcError(); + expect(rpc.code).toBe(-32002); + expect(rpc.data).toEqual({ uri: 'test://resource' }); + }); +}); +``` + +### Testing Constructor Validation + +Always test that constructors throw on invalid input: + +```typescript +describe('MyService constructor', () => { + it('should throw when required config is missing', () => { + expect(() => new MyService({})).toThrow(); + }); + + it('should accept valid config', () => { + const service = new MyService({ endpoint: 'https://example.com' }); + expect(service).toBeDefined(); + }); +}); +``` + +## E2E Testing with @frontmcp/testing + +The `@frontmcp/testing` library provides a full E2E testing framework with a test client, server lifecycle management, custom matchers, and fixture utilities. + +### Key Exports from @frontmcp/testing + +```typescript +import { + // Primary API (fixture-based) + test, + expect, + + // Manual client API + McpTestClient, + McpTestClientBuilder, + + // Server management + TestServer, + + // Auth testing + TestTokenFactory, + AuthHeaders, + TestUsers, + MockOAuthServer, + MockAPIServer, + MockCimdServer, + + // Assertions & matchers + McpAssertions, + mcpMatchers, + + // Interceptors & mocking + DefaultMockRegistry, + DefaultInterceptorChain, + mockResponse, + interceptors, + httpMock, + httpResponse, + + // Performance testing + perfTest, + MetricsCollector, + LeakDetector, + BaselineStore, + RegressionDetector, + ReportGenerator, + + // Low-level client + McpClient, + McpStdioClientTransport, +} from '@frontmcp/testing'; +``` + +### Install the Testing Package + +```bash +yarn add -D @frontmcp/testing +``` + +### Fixture-Based E2E Tests (Recommended) + +The fixture API manages server lifecycle automatically: + +```typescript +// my-server.e2e.spec.ts +import { test, expect } from '@frontmcp/testing'; + +test.use({ + server: './src/main.ts', + port: 3003, +}); + +test('server exposes expected tools', async ({ mcp }) => { + const tools = await mcp.tools.list(); + expect(tools).toContainTool('create_record'); + expect(tools).toContainTool('delete_record'); +}); + +test('create_record tool returns success', async ({ mcp }) => { + const result = await mcp.tools.call('create_record', { + name: 'Test Record', + type: 'example', + }); + + expect(result).toBeSuccessful(); + expect(result).toHaveTextContent('created'); +}); + +test('reading a resource returns valid content', async ({ mcp }) => { + const result = await mcp.resources.read('config://server-info'); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0]).toHaveProperty('mimeType', 'application/json'); +}); + +test('prompts return well-formed messages', async ({ mcp }) => { + const result = await mcp.prompts.get('summarize', { topic: 'testing' }); + + expect(result.messages).toBeDefined(); + expect(result.messages.length).toBeGreaterThan(0); +}); +``` + +### Manual Client E2E Tests + +For more control, use `McpTestClient` and `TestServer` directly: + +```typescript +// advanced.e2e.spec.ts +import { McpTestClient, TestServer } from '@frontmcp/testing'; + +describe('Advanced E2E', () => { + let server: TestServer; + let client: McpTestClient; + + beforeAll(async () => { + server = await TestServer.start({ + command: 'npx tsx src/main.ts', + port: 3004, + }); + + client = await McpTestClient.create({ baseUrl: server.info.baseUrl }) + .withTransport('modern') // 'modern' preset enables streamable HTTP + strict sessions + .buildAndConnect(); + }); + + afterAll(async () => { + await client.disconnect(); + await server.stop(); + }); + + it('should list tools after initialization', async () => { + const tools = await client.tools.list(); + expect(tools.length).toBeGreaterThan(0); + }); + + it('should handle tool errors gracefully', async () => { + const result = await client.tools.call('nonexistent_tool', {}); + expect(result).toBeError(); + }); +}); +``` + +### Testing with Authentication + +```typescript +import { test, expect, TestTokenFactory } from '@frontmcp/testing'; + +test.use({ + server: './src/main.ts', + port: 3005, + auth: { + issuer: 'https://auth.example.com/', + audience: 'https://api.example.com', + }, +}); + +test('authenticated tool call succeeds', async ({ mcp, auth }) => { + const token = await auth.createToken({ sub: 'user-123', scopes: ['tools:read'] }); + mcp.setAuthToken(token); + + const result = await mcp.tools.call('get_user_profile', {}); + expect(result).toBeSuccessful(); +}); + +test('unauthenticated call is rejected', async ({ mcp }) => { + mcp.clearAuthToken(); + + const result = await mcp.tools.call('get_user_profile', {}); + expect(result).toBeError(); +}); +``` + +## Custom MCP Matchers + +`@frontmcp/testing` provides Jest matchers tailored for MCP responses. Import `expect` from `@frontmcp/testing` instead of from Jest: + +```typescript +import { expect } from '@frontmcp/testing'; +``` + +| Matcher | Asserts | +| ------------------------- | ----------------------------------------------------- | +| `toContainTool(name)` | Tools list includes a tool with the given name | +| `toContainResource(uri)` | Resources list includes a resource with the given URI | +| `toContainPrompt(name)` | Prompts list includes a prompt with the given name | +| `toBeSuccessful()` | Tool call result is not an error | +| `toBeError()` | Tool call result is an MCP error | +| `toHaveTextContent(text)` | Result contains text content matching the string | +| `toHaveMimeType(mime)` | Resource content has the expected MIME type | + +## Running Tests with Nx + +FrontMCP uses Nx as its build system. Run tests with these commands: + +```bash +# Run all tests for a specific library +nx test sdk + +# Run tests for a specific file +nx test my-app --testFile=src/tools/__tests__/my-tool.spec.ts + +# Run all tests across the monorepo +nx run-many -t test + +# Run with coverage +nx test sdk --coverage + +# Run only E2E tests (by pattern) +nx test sdk --testPathPattern='\.e2e\.spec\.ts$' + +# Run a single test by name +nx test sdk --testNamePattern='should return formatted output' +``` + +## Jest Configuration + +Each library has its own `jest.config.ts`. Coverage thresholds are enforced per library: + +```typescript +// jest.config.ts +export default { + displayName: 'my-lib', + preset: '../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + coverageThreshold: { + global: { + statements: 95, + branches: 95, + functions: 95, + lines: 95, + }, + }, +}; +``` + +## Manual Testing with frontmcp dev + +For interactive development and manual testing, use the CLI: + +```bash +# Start the dev server with hot reload +frontmcp dev + +# Start on a specific port +frontmcp dev --port 4000 + +# The dev server exposes your MCP server over Streamable HTTP +# Connect any MCP client (Claude Desktop, cursor, etc.) to test interactively +``` + +This is useful for: + +- Verifying tool behavior with a real AI client +- Testing the full request/response cycle +- Debugging issues that are hard to reproduce in automated tests +- Validating authentication flows end-to-end + +## Cleanup Before Committing + +Always run the unused import cleanup script on changed files: + +```bash +# Remove unused imports from files changed vs main +node scripts/fix-unused-imports.mjs + +# Custom base branch +node scripts/fix-unused-imports.mjs feature/my-branch +``` + +## Testing Patterns Summary + +| What to Test | How | File Suffix | +| ------------------------ | ------------------------------------------------- | --------------- | +| Tool execute logic | Unit test with mock context | `.spec.ts` | +| Resource read logic | Unit test with mock params | `.spec.ts` | +| Prompt output shape | Unit test verifying GetPromptResult | `.spec.ts` | +| Full MCP protocol flow | E2E with McpTestClient | `.e2e.spec.ts` | +| Error handling | Unit test verifying specific error classes/codes | `.spec.ts` | +| Plugin behavior | Unit test providers + integration via test server | `.spec.ts` | +| Performance regression | Perf tests with MetricsCollector | `.perf.spec.ts` | +| Playwright browser tests | UI tests with Playwright | `.pw.spec.ts` | +| Constructor validation | Unit test verifying throws on invalid input | `.spec.ts` | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| ---------------- | -------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ | +| Test file naming | `my-tool.spec.ts`, `my-tool.e2e.spec.ts` | `my-tool.test.ts`, `my-tool.test.tsx` | Nx and Jest configs only recognize `.spec.ts` convention | +| Test description | `'should return formatted output for valid input'` | `'PT-001: test formatted output'` | Descriptive names; no ID prefixes | +| Mock types | `const ctx = { scope: { get: jest.fn() } } as unknown` | `const ctx: any = { scope: { get: jest.fn() } }` | Strict TypeScript; avoid `any` in mocks | +| Error assertion | `expect(err).toBeInstanceOf(ResourceNotFoundError)` | `expect(() => ...).toThrow()` | Verify the exact error class and MCP error code, not just that something threw | +| Constructor test | Always test `new MyService({})` throws on invalid config | Skip constructor validation | Catches misconfiguration early; required for 95% branch coverage | +| E2E test imports | `import { test, expect } from '@frontmcp/testing'` | `import { expect } from '@jest/globals'` | `@frontmcp/testing` provides MCP-specific matchers like `toContainTool()` | +| Coverage check | `nx test my-lib --coverage` before push | Push without coverage check | CI enforces 95% thresholds; catch failures locally first | + +## Verification Checklist + +### Configuration + +- [ ] Jest config exists with `coverageThreshold` set to 95% for all metrics +- [ ] `tsconfig.spec.json` exists and extends the base tsconfig +- [ ] `@frontmcp/testing` is installed as a dev dependency for E2E tests +- [ ] Test files use `.spec.ts` (unit), `.e2e.spec.ts` (E2E), or `.perf.spec.ts` (perf) extension + +### Unit Tests + +- [ ] Each tool's `execute()` method is tested with valid and invalid inputs +- [ ] Each resource's `read()` method is tested and output matches `ReadResourceResult` shape +- [ ] Each prompt's `execute()` method is tested and output matches `GetPromptResult` shape +- [ ] Constructor validation tests verify throws on invalid config +- [ ] Error classes are verified with `instanceof` checks and `mcpErrorCode` assertions + +### E2E Tests + +- [ ] Fixture-based tests use `test.use({ server, port })` for server lifecycle +- [ ] Tools appear in `tools/list` response via `toContainTool()` matcher +- [ ] Tool calls return expected results via `toBeSuccessful()` matcher +- [ ] Authenticated tests use `TestTokenFactory` and verify rejection without token + +### CI Integration + +- [ ] `nx test --coverage` passes locally with 95%+ on all metrics +- [ ] Unused imports are cleaned via `node scripts/fix-unused-imports.mjs` +- [ ] No TypeScript warnings in test files + +## Troubleshooting + +| Problem | Cause | Solution | +| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Jest cannot find test files | Files use `.test.ts` instead of `.spec.ts` | Rename to `.spec.ts`; Nx test runner only picks up `.spec.ts` by default | +| Coverage below 95% threshold | Untested branches or constructor paths | Run `nx test --coverage` and check the HTML report for uncovered lines | +| E2E test times out on `TestServer.start()` | Server entrypoint fails to start or wrong port | Verify `server` path and `port` in `test.use()`; check server logs for startup errors | +| `toContainTool` matcher not found | Using `expect` from Jest instead of `@frontmcp/testing` | Import `expect` from `@frontmcp/testing` to get MCP-specific matchers | +| `McpTestClient.create()` connection refused | Test server not running or wrong `baseUrl` | Ensure `TestServer.start()` completes before creating client; verify port matches | +| Istanbul shows 0% coverage for async methods | TypeScript compilation source-map mismatch | Known issue with `ts-jest` and certain async patterns; check `tsconfig.spec.json` source-map settings | +| Auth E2E test returns 401 unexpectedly | Token not set or expired | Call `mcp.setAuthToken(token)` before the tool call; use `auth.createToken()` with valid claims | + +## Reference + +- [Testing Documentation](https://docs.agentfront.dev/frontmcp/testing/overview) +- Related skills: `create-tool`, `create-resource`, `create-prompt`, `setup-project`, `nx-workflow` diff --git a/libs/skills/catalog/frontmcp-testing/references/test-auth.md b/libs/skills/catalog/frontmcp-testing/references/test-auth.md new file mode 100644 index 00000000..553ed3c2 --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/test-auth.md @@ -0,0 +1,88 @@ +# Testing with Authentication + +```typescript +import { McpTestClient, TestServer, TestTokenFactory, MockOAuthServer } from '@frontmcp/testing'; +import Server from '../src/main'; + +describe('Authenticated Server', () => { + let server: TestServer; + let tokenFactory: TestTokenFactory; + + beforeAll(async () => { + server = await TestServer.create(Server); + tokenFactory = new TestTokenFactory({ + issuer: 'https://test-idp.example.com', + audience: 'my-api', + }); + }); + + afterAll(async () => { + await server.dispose(); + }); + + it('should reject unauthenticated requests', async () => { + const client = await server.connect(); + const result = await client.callTool('protected_tool', {}); + expect(result.isError).toBe(true); + await client.close(); + }); + + it('should accept valid token', async () => { + const token = await tokenFactory.createToken({ + sub: 'user-123', + scopes: ['read', 'write'], + }); + + const client = await server.connect({ authToken: token }); + const result = await client.callTool('protected_tool', { data: 'test' }); + expect(result).toBeSuccessful(); + await client.close(); + }); + + it('should enforce role-based access', async () => { + const adminToken = await tokenFactory.createToken({ + sub: 'admin-1', + roles: ['admin'], + }); + const userToken = await tokenFactory.createToken({ + sub: 'user-1', + roles: ['user'], + }); + + const adminClient = await server.connect({ authToken: adminToken }); + const adminResult = await adminClient.callTool('admin_only_tool', {}); + expect(adminResult).toBeSuccessful(); + + const userClient = await server.connect({ authToken: userToken }); + const userResult = await userClient.callTool('admin_only_tool', {}); + expect(userResult.isError).toBe(true); + + await adminClient.close(); + await userClient.close(); + }); +}); + +describe('OAuth Flow', () => { + let mockOAuth: MockOAuthServer; + + beforeAll(async () => { + mockOAuth = await MockOAuthServer.create({ + issuer: 'https://test-idp.example.com', + port: 9999, + }); + }); + + afterAll(async () => { + await mockOAuth.close(); + }); + + it('should complete OAuth authorization code flow', async () => { + const { authorizationUrl } = await mockOAuth.startFlow({ + clientId: 'test-client', + redirectUri: 'http://localhost:3001/callback', + scopes: ['openid', 'profile'], + }); + expect(authorizationUrl).toContain('code='); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-testing/references/test-browser-build.md b/libs/skills/catalog/frontmcp-testing/references/test-browser-build.md new file mode 100644 index 00000000..be745e89 --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/test-browser-build.md @@ -0,0 +1,57 @@ +# Testing Browser Build + +After building with `frontmcp build --target browser`, validate the output: + +```typescript +import * as fs from 'fs'; +import * as path from 'path'; + +const DIST_DIR = path.resolve(__dirname, '../dist/browser'); + +describe('Browser Build', () => { + it('should produce browser-compatible bundle', () => { + const files = fs.readdirSync(DIST_DIR); + expect(files.some((f) => f.endsWith('.js'))).toBe(true); + }); + + it('should not contain Node.js-only modules', () => { + const bundle = fs.readFileSync(path.join(DIST_DIR, 'index.js'), 'utf-8'); + // These should be polyfilled or excluded + expect(bundle).not.toContain("require('fs')"); + expect(bundle).not.toContain("require('child_process')"); + }); + + it('should export expected functions', async () => { + // Use dynamic import to test ESM compatibility + const mod = await import(path.join(DIST_DIR, 'index.js')); + expect(mod).toBeDefined(); + }); +}); +``` + +## Testing with Playwright (.pw.spec.ts) + +```typescript +import { test, expect } from '@playwright/test'; + +test('browser MCP client loads tools', async ({ page }) => { + await page.goto('http://localhost:3000'); + + // Wait for tools to load from MCP server + await page.waitForSelector('[data-testid="tool-list"]'); + + const tools = await page.locator('[data-testid="tool-item"]').count(); + expect(tools).toBeGreaterThan(0); +}); + +test('browser client can call a tool', async ({ page }) => { + await page.goto('http://localhost:3000'); + + await page.fill('[data-testid="input-a"]', '5'); + await page.fill('[data-testid="input-b"]', '3'); + await page.click('[data-testid="call-tool"]'); + + const result = await page.textContent('[data-testid="result"]'); + expect(result).toContain('8'); +}); +``` diff --git a/libs/skills/catalog/frontmcp-testing/references/test-cli-binary.md b/libs/skills/catalog/frontmcp-testing/references/test-cli-binary.md new file mode 100644 index 00000000..6f0c97c5 --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/test-cli-binary.md @@ -0,0 +1,48 @@ +# Testing CLI Binary / SEA Build + +After building with `frontmcp build --target cli`, test the binary: + +```typescript +import { execSync, spawn } from 'child_process'; +import * as path from 'path'; + +const BINARY = path.resolve(__dirname, '../dist/my-server'); + +describe('CLI Binary', () => { + it('should start and respond to health check', async () => { + const child = spawn(BINARY, [], { + env: { ...process.env, PORT: '0' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Wait for server to start + await new Promise((resolve) => { + child.stdout.on('data', (data: Buffer) => { + if (data.toString().includes('listening')) resolve(); + }); + }); + + // Test health endpoint + const res = await fetch('http://localhost:3001/health'); + expect(res.ok).toBe(true); + + child.kill(); + }); + + it('should exit with code 0 on --help', () => { + const output = execSync(`${BINARY} --help`, { encoding: 'utf-8' }); + expect(output).toContain('Usage'); + }); +}); +``` + +## Testing JS Bundle + +```typescript +describe('JS Bundle', () => { + it('should be importable', async () => { + const mod = await import('../dist/my-server.cjs.js'); + expect(mod).toBeDefined(); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-testing/references/test-direct-client.md b/libs/skills/catalog/frontmcp-testing/references/test-direct-client.md new file mode 100644 index 00000000..5dbae36a --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/test-direct-client.md @@ -0,0 +1,62 @@ +# Testing with Direct Client (No HTTP) + +Uses `connect()` or `create()` for in-memory testing without HTTP overhead. + +```typescript +import { create, connectOpenAI } from '@frontmcp/sdk'; +import { tool } from '@frontmcp/sdk'; +import { z } from 'zod'; + +const AddTool = tool({ + name: 'add', + description: 'Add numbers', + inputSchema: { a: z.number(), b: z.number() }, + outputSchema: { sum: z.number() }, +})((input) => ({ sum: input.a + input.b })); + +describe('Direct Client Testing', () => { + it('should call tools via create()', async () => { + const server = await create({ + info: { name: 'test', version: '1.0.0' }, + tools: [AddTool], + cacheKey: 'test-direct', + }); + + const result = await server.callTool('add', { a: 2, b: 3 }); + expect(result.content[0].text).toContain('5'); + + await server.dispose(); + }); + + it('should return OpenAI-formatted tools', async () => { + const client = await connectOpenAI({ + info: { name: 'test', version: '1.0.0' }, + tools: [AddTool], + serve: false, + }); + + const tools = await client.listTools(); + // OpenAI format: [{ type: 'function', function: { name, parameters } }] + expect(tools[0].type).toBe('function'); + expect(tools[0].function.name).toBe('add'); + + await client.close(); + }); + + it('should return Claude-formatted tools', async () => { + const { connectClaude } = await import('@frontmcp/sdk'); + const client = await connectClaude({ + info: { name: 'test', version: '1.0.0' }, + tools: [AddTool], + serve: false, + }); + + const tools = await client.listTools(); + // Claude format: [{ name, description, input_schema }] + expect(tools[0].name).toBe('add'); + expect(tools[0].input_schema).toBeDefined(); + + await client.close(); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-testing/references/test-e2e-handler.md b/libs/skills/catalog/frontmcp-testing/references/test-e2e-handler.md new file mode 100644 index 00000000..ce071600 --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/test-e2e-handler.md @@ -0,0 +1,51 @@ +# E2E Testing with McpTestClient (HTTP Handler) + +Tests the full MCP protocol over HTTP — validates tools, resources, prompts end-to-end. + +```typescript +import { McpTestClient, TestServer } from '@frontmcp/testing'; +import Server from '../src/main'; + +describe('Server E2E', () => { + let client: McpTestClient; + let server: TestServer; + + beforeAll(async () => { + server = await TestServer.create(Server); + client = await server.connect(); + }); + + afterAll(async () => { + await client.close(); + await server.dispose(); + }); + + it('should list all tools', async () => { + const { tools } = await client.listTools(); + expect(tools.length).toBeGreaterThan(0); + expect(tools).toContainTool('add_numbers'); + }); + + it('should call a tool and get result', async () => { + const result = await client.callTool('add_numbers', { a: 5, b: 3 }); + expect(result).toBeSuccessful(); + expect(result.content[0].text).toContain('8'); + }); + + it('should return error for invalid input', async () => { + const result = await client.callTool('add_numbers', { a: 'bad' }); + expect(result.isError).toBe(true); + }); + + it('should list resources', async () => { + const { resources } = await client.listResources(); + expect(resources.length).toBeGreaterThanOrEqual(0); + }); + + it('should get a prompt', async () => { + const result = await client.getPrompt('summarize', { topic: 'testing' }); + expect(result.messages).toBeDefined(); + expect(result.messages.length).toBeGreaterThan(0); + }); +}); +``` diff --git a/libs/skills/catalog/frontmcp-testing/references/test-tool-unit.md b/libs/skills/catalog/frontmcp-testing/references/test-tool-unit.md new file mode 100644 index 00000000..f63fcda1 --- /dev/null +++ b/libs/skills/catalog/frontmcp-testing/references/test-tool-unit.md @@ -0,0 +1,42 @@ +# Unit Testing a Tool + +```typescript +import { z } from 'zod'; +import { ToolContext } from '@frontmcp/sdk'; +import { AddTool } from '../tools/add.tool'; + +describe('AddTool', () => { + it('should add two numbers', async () => { + // Create mock context + const ctx = { + get: jest.fn(), + tryGet: jest.fn(), + fail: jest.fn((err) => { + throw err; + }), + mark: jest.fn(), + notify: jest.fn(), + respondProgress: jest.fn(), + } as unknown as ToolContext; + + const tool = new AddTool(); + Object.assign(tool, ctx); + + const result = await tool.execute({ a: 2, b: 3 }); + expect(result).toEqual({ sum: 5 }); + }); + + it('should handle negative numbers', async () => { + const tool = new AddTool(); + const result = await tool.execute({ a: -1, b: -2 }); + expect(result).toEqual({ sum: -3 }); + }); + + it('should throw on invalid input', async () => { + const tool = new AddTool(); + // Zod validates before execute — test the schema separately + const schema = z.object({ a: z.number(), b: z.number() }); + expect(() => schema.parse({ a: 'not-a-number' })).toThrow(); + }); +}); +``` diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json new file mode 100644 index 00000000..d16fc12d --- /dev/null +++ b/libs/skills/catalog/skills-manifest.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "skills": [ + { + "name": "frontmcp-setup", + "category": "setup", + "description": "Domain router for project setup and scaffolding \u2014 new projects, project structure, Nx workspaces, storage backends, multi-app composition, and the skills system. Use when starting or organizing a FrontMCP project.", + "path": "frontmcp-setup", + "targets": ["all"], + "hasResources": true, + "tags": ["router", "setup", "scaffold", "project", "nx", "redis", "sqlite", "structure", "guide"], + "bundle": ["recommended", "minimal", "full"] + }, + { + "name": "frontmcp-development", + "category": "development", + "description": "Domain router for building MCP components \u2014 tools, resources, prompts, agents, providers, jobs, workflows, and skills. Use when starting any FrontMCP development task and need to find the right skill.", + "path": "frontmcp-development", + "targets": ["all"], + "hasResources": true, + "tags": ["router", "development", "tools", "resources", "prompts", "agents", "skills", "guide"], + "bundle": ["recommended", "minimal", "full"] + }, + { + "name": "frontmcp-deployment", + "category": "deployment", + "description": "Domain router for shipping MCP servers \u2014 deploy to Node, Vercel, Lambda, Cloudflare, or build for CLI, browser, and SDK. Use when choosing a deployment target or build format.", + "path": "frontmcp-deployment", + "targets": ["all"], + "hasResources": true, + "tags": ["router", "deployment", "node", "vercel", "lambda", "cloudflare", "cli", "browser", "sdk", "guide"], + "bundle": ["recommended", "minimal", "full"] + }, + { + "name": "frontmcp-testing", + "category": "testing", + "description": "Domain router for testing MCP servers \u2014 unit tests, E2E tests, coverage, and quality assurance. Use when starting any testing task for a FrontMCP application.", + "path": "frontmcp-testing", + "targets": ["all"], + "hasResources": true, + "tags": ["router", "testing", "jest", "e2e", "coverage", "quality", "guide"], + "bundle": ["recommended", "full"] + }, + { + "name": "frontmcp-config", + "category": "config", + "description": "Domain router for configuring MCP servers \u2014 transport, HTTP, throttle, elicitation, auth, sessions, and storage. Use when configuring any aspect of a FrontMCP server.", + "path": "frontmcp-config", + "targets": ["all"], + "hasResources": true, + "tags": ["router", "config", "transport", "http", "auth", "session", "redis", "sqlite", "throttle", "guide"], + "bundle": ["recommended", "full"] + }, + { + "name": "frontmcp-guides", + "category": "guides", + "description": "End-to-end examples and best practices for building FrontMCP MCP servers. Use when starting a new project from scratch, learning architectural patterns, or following a complete build walkthrough.", + "path": "frontmcp-guides", + "targets": ["all"], + "hasResources": true, + "tags": ["guides", "examples", "best-practices", "architecture", "walkthrough", "end-to-end"], + "bundle": ["recommended", "full"] + } + ] +} diff --git a/libs/skills/eslint.config.mjs b/libs/skills/eslint.config.mjs new file mode 100644 index 00000000..dc198e4d --- /dev/null +++ b/libs/skills/eslint.config.mjs @@ -0,0 +1,11 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.spec.ts'], + rules: { + '@nx/enforce-module-boundaries': 'off', + }, + }, +]; diff --git a/libs/skills/jest.config.ts b/libs/skills/jest.config.ts new file mode 100644 index 00000000..93317837 --- /dev/null +++ b/libs/skills/jest.config.ts @@ -0,0 +1,45 @@ +module.exports = { + displayName: '@frontmcp/skills', + preset: '../../jest.preset.js', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.spec.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + decorators: true, + dynamicImport: true, + }, + transform: { + decoratorMetadata: true, + legacyDecorator: true, + }, + keepClassNames: true, + externalHelpers: true, + loose: true, + }, + module: { + type: 'es6', + }, + sourceMaps: true, + swcrc: false, + }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/unit/skills', + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'], + coverageThreshold: { + global: { + statements: 95, + branches: 95, + functions: 95, + lines: 95, + }, + }, +}; diff --git a/libs/skills/package.json b/libs/skills/package.json new file mode 100644 index 00000000..df37846a --- /dev/null +++ b/libs/skills/package.json @@ -0,0 +1,33 @@ +{ + "name": "@frontmcp/skills", + "version": "1.0.0-beta.8", + "description": "Curated skills catalog for FrontMCP projects", + "author": "AgentFront ", + "homepage": "https://docs.agentfront.dev", + "license": "Apache-2.0", + "keywords": [ + "skills", + "mcp", + "agentfront", + "frontmcp", + "catalog", + "agent-skills", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/agentfront/frontmcp.git", + "directory": "libs/skills" + }, + "bugs": { + "url": "https://github.com/agentfront/frontmcp/issues" + }, + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/libs/skills/project.json b/libs/skills/project.json new file mode 100644 index 00000000..74b177f7 --- /dev/null +++ b/libs/skills/project.json @@ -0,0 +1,56 @@ +{ + "name": "skills", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/skills/src", + "projectType": "library", + "tags": ["scope:libs", "scope:publishable", "versioning:synchronized"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/skills/jest.config.ts" + } + }, + "build-tsc": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "libs/skills/dist", + "main": "libs/skills/src/index.ts", + "tsConfig": "libs/skills/tsconfig.lib.json", + "assets": [ + "README.md", + "LICENSE", + { + "glob": "**/*", + "input": "libs/skills/catalog", + "output": "catalog" + } + ] + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": ["build-tsc"], + "options": { + "command": "node scripts/strip-dist-from-pkg.js libs/skills/dist/package.json" + } + }, + "publish": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "npm publish libs/skills/dist --access public --registry=https://registry.npmjs.org/" + } + }, + "publish-alpha": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "bash scripts/publish-alpha.sh {projectRoot}/dist", + "cwd": "{workspaceRoot}" + } + } + } +} diff --git a/libs/skills/src/index.ts b/libs/skills/src/index.ts new file mode 100644 index 00000000..b8ce28e4 --- /dev/null +++ b/libs/skills/src/index.ts @@ -0,0 +1,22 @@ +export type { + SkillCatalogEntry, + SkillManifest, + SkillTarget, + SkillCategory, + SkillBundle, + SkillDestination, + SkillMergeStrategy, + SkillInstallConfig, +} from './manifest'; + +export { VALID_TARGETS, VALID_CATEGORIES, VALID_BUNDLES } from './manifest'; + +export { + loadManifest, + getSkillsByTarget, + getSkillsByCategory, + getSkillsByBundle, + getInstructionOnlySkills, + getResourceSkills, + resolveSkillPath, +} from './loader'; diff --git a/libs/skills/src/loader.ts b/libs/skills/src/loader.ts new file mode 100644 index 00000000..33639b09 --- /dev/null +++ b/libs/skills/src/loader.ts @@ -0,0 +1,77 @@ +/** + * Skills catalog loader and filtering helpers. + * + * Provides functions to query the catalog manifest by target, category, and bundle. + * + * @module skills/loader + */ + +import * as path from 'node:path'; +import type { SkillCatalogEntry, SkillManifest } from './manifest'; + +/** + * Load the skills manifest from the catalog directory. + * + * @param catalogDir - Absolute path to the catalog directory. Defaults to the bundled catalog. + * @returns The parsed skills manifest + */ +export function loadManifest(catalogDir?: string): SkillManifest { + const dir = catalogDir ?? path.resolve(__dirname, '..', 'catalog'); + const manifestPath = path.join(dir, 'skills-manifest.json'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(manifestPath) as SkillManifest; +} + +/** + * Filter skills by deployment target. + * Returns skills that include the given target or 'all'. + */ +export function getSkillsByTarget(skills: SkillCatalogEntry[], target: string): SkillCatalogEntry[] { + return skills.filter( + (s) => s.targets.includes('all') || s.targets.includes(target as SkillCatalogEntry['targets'][number]), + ); +} + +/** + * Filter skills by category. + */ +export function getSkillsByCategory(skills: SkillCatalogEntry[], category: string): SkillCatalogEntry[] { + return skills.filter((s) => s.category === category); +} + +/** + * Filter skills by bundle membership. + */ +export function getSkillsByBundle(skills: SkillCatalogEntry[], bundle: string): SkillCatalogEntry[] { + return skills.filter((s) => + s.bundle?.includes(bundle as SkillCatalogEntry['bundle'] extends (infer U)[] | undefined ? U : never), + ); +} + +/** + * Get only instruction-only skills (no scripts/, references/, or assets/ directories). + * These are safe to use with `instructions: { file: ... }` wrappers. + */ +export function getInstructionOnlySkills(skills: SkillCatalogEntry[]): SkillCatalogEntry[] { + return skills.filter((s) => !s.hasResources); +} + +/** + * Get only resource-carrying skills (have scripts/, references/, or assets/). + * These need full directory loading via `skillDir()`. + */ +export function getResourceSkills(skills: SkillCatalogEntry[]): SkillCatalogEntry[] { + return skills.filter((s) => s.hasResources); +} + +/** + * Resolve the absolute path to a skill directory. + * + * @param entry - The catalog entry + * @param catalogDir - Absolute path to the catalog directory + * @returns Absolute path to the skill directory + */ +export function resolveSkillPath(entry: SkillCatalogEntry, catalogDir?: string): string { + const dir = catalogDir ?? path.resolve(__dirname, '..', 'catalog'); + return path.resolve(dir, entry.path); +} diff --git a/libs/skills/src/manifest.ts b/libs/skills/src/manifest.ts new file mode 100644 index 00000000..2ced85ab --- /dev/null +++ b/libs/skills/src/manifest.ts @@ -0,0 +1,99 @@ +/** + * Skills catalog manifest types. + * + * Defines the contract between the catalog, scaffold tooling, and future installer. + * + * @module skills/manifest + */ + +/** + * Supported deployment targets for skill filtering. + */ +export type SkillTarget = 'node' | 'vercel' | 'lambda' | 'cloudflare' | 'all'; + +/** + * Skill categories for organizing the catalog. + */ +export type SkillCategory = 'setup' | 'deployment' | 'development' | 'config' | 'testing' | 'guides'; + +/** + * Bundle membership for curated scaffold presets. + */ +export type SkillBundle = 'recommended' | 'minimal' | 'full'; + +/** + * Install destination types for future provider wiring. + */ +export type SkillDestination = 'project-local' | '.claude/skills' | 'codex' | 'gemini'; + +/** + * Merge strategy when installing a skill that already exists at the destination. + */ +export type SkillMergeStrategy = 'overwrite' | 'skip-existing'; + +/** + * Install configuration for a catalog skill. + */ +export interface SkillInstallConfig { + /** Where this skill can be installed */ + destinations: SkillDestination[]; + /** How to handle existing skills at the destination */ + mergeStrategy: SkillMergeStrategy; + /** Other skills this depends on (by name) */ + dependencies?: string[]; +} + +/** + * A single entry in the skills catalog manifest. + * + * This is the core contract connecting SKILL.md files to scaffolding, + * future installation, and provider-specific destinations. + */ +export interface SkillCatalogEntry { + /** Unique skill name — matches SKILL.md frontmatter `name` */ + name: string; + /** Skill category for organization */ + category: SkillCategory; + /** Short description */ + description: string; + /** Path to the skill directory, relative to catalog/ */ + path: string; + /** Deployment targets this skill applies to */ + targets: SkillTarget[]; + /** Whether the skill has scripts/, references/, or assets/ directories */ + hasResources: boolean; + /** Target-specific storage defaults (e.g., { node: 'redis-docker', vercel: 'vercel-kv' }) */ + storageDefault?: Record; + /** Tags for secondary filtering and search */ + tags: string[]; + /** Bundle membership for scaffold presets */ + bundle?: SkillBundle[]; + /** Install configuration for future distribution (optional — not yet used by CLI) */ + install?: SkillInstallConfig; +} + +/** + * The skills catalog manifest — single source of truth for scaffold and install tooling. + */ +export interface SkillManifest { + /** Manifest schema version */ + version: 1; + /** All catalog skills */ + skills: SkillCatalogEntry[]; +} + +/** Valid deployment targets for manifest validation */ +export const VALID_TARGETS: readonly SkillTarget[] = ['node', 'vercel', 'lambda', 'cloudflare', 'all']; + +/** Valid categories for manifest validation */ +export const VALID_CATEGORIES: readonly SkillCategory[] = [ + 'setup', + 'deployment', + 'development', + 'config', + 'testing', + 'guides', +]; + +/** Valid bundles for manifest validation */ +export const VALID_BUNDLES: readonly SkillBundle[] = ['recommended', 'minimal', 'full']; diff --git a/libs/skills/tsconfig.json b/libs/skills/tsconfig.json new file mode 100644 index 00000000..6b3ec885 --- /dev/null +++ b/libs/skills/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "strictNullChecks": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": false + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/skills/tsconfig.lib.json b/libs/skills/tsconfig.lib.json new file mode 100644 index 00000000..dd7431f5 --- /dev/null +++ b/libs/skills/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/__tests__/**"] +} diff --git a/libs/skills/tsconfig.spec.json b/libs/skills/tsconfig.spec.json new file mode 100644 index 00000000..10d37a65 --- /dev/null +++ b/libs/skills/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.ts", "__tests__/**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index c902536b..598d0f7c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -82,6 +82,7 @@ "@frontmcp/protocol": ["libs/protocol/src/index.ts"], "@frontmcp/auth": ["libs/auth/src/index.ts"], "@frontmcp/guard": ["libs/guard/src/index.ts"], + "@frontmcp/skills": ["libs/skills/src/index.ts"], "@frontmcp/storage-sqlite": ["libs/storage-sqlite/src/index.ts"], "@frontmcp/nx": ["libs/nx-plugin/src/index.ts"], "@frontmcp/react": ["libs/react/src/index.ts"],