diff --git a/libs/skills/__tests__/manifest.spec.ts b/libs/skills/__tests__/manifest.spec.ts index af9769ed4..6e1ed25e8 100644 --- a/libs/skills/__tests__/manifest.spec.ts +++ b/libs/skills/__tests__/manifest.spec.ts @@ -2,7 +2,7 @@ * Skills manifest validation tests. */ -import { VALID_TARGETS, VALID_CATEGORIES, VALID_BUNDLES } from '../src/manifest'; +import { VALID_BUNDLES, VALID_CATEGORIES, VALID_TARGETS } from '../src/manifest'; describe('manifest constants', () => { it('should export valid targets', () => { @@ -24,7 +24,8 @@ describe('manifest constants', () => { expect(VALID_CATEGORIES).toContain('production'); expect(VALID_CATEGORIES).toContain('extensibility'); expect(VALID_CATEGORIES).toContain('observability'); - expect(VALID_CATEGORIES).toHaveLength(9); + expect(VALID_CATEGORIES).toContain('development/create'); + expect(VALID_CATEGORIES).toHaveLength(10); }); it('should export valid bundles', () => { diff --git a/libs/skills/__tests__/skills-validation.spec.ts b/libs/skills/__tests__/skills-validation.spec.ts index a49becf20..2cfa518de 100644 --- a/libs/skills/__tests__/skills-validation.spec.ts +++ b/libs/skills/__tests__/skills-validation.spec.ts @@ -29,6 +29,36 @@ function loadManifestSync(): SkillManifest { return JSON.parse(content) as SkillManifest; } +/** + * Resolve the catalog layout for a skill directory by consulting the + * manifest. `layout: 'component'` skills use a different SKILL.md + * structure (rich frontmatter for Claude auto-trigger, flat examples, + * no TEMPLATE.md-derived section headings) and need a different set + * of structural checks than the legacy `'router'` layout. + * + * Lazy-cached so we don't re-parse the manifest per assertion. The cache + * is reset in a top-level `beforeEach` (see the describe block below) so + * watch-mode reruns pick up edits to `skills-manifest.json` instead of + * holding a stale layout map across runs. + */ +let _layoutCache: Map | undefined; +function getSkillLayout(dir: string): 'router' | 'component' { + if (!_layoutCache) { + _layoutCache = new Map(); + const manifest = loadManifestSync(); + for (const entry of manifest.skills) { + _layoutCache.set(entry.path, entry.layout ?? 'router'); + } + } + return _layoutCache.get(dir) ?? 'router'; +} +function resetSkillLayoutCache(): void { + _layoutCache = undefined; +} +function isComponentLayout(dir: string): boolean { + return getSkillLayout(dir) === 'component'; +} + function findAllSkillDirs(): string[] { const dirs: string[] = []; const entries = fs.readdirSync(CATALOG_DIR).filter((f) => { @@ -182,6 +212,20 @@ function getAllReferenceFiles(): { skill: string; file: string; fullPath: string return results; } +/** + * Walk every skill in the catalog and surface its example files. + * + * Handles both layouts: + * + * - Router (legacy `frontmcp-*` skills): `examples//.md` + * — examples are grouped under a parent reference. `reference` is set to + * the subdirectory name. + * + * - Component (new per-thing skills like `create-tool`): `examples/.md` + * — examples live flat under `examples/` with no parent reference. `reference` + * is set to the sentinel `'_top'` so callers can detect the layout without + * stat'ing the path again. + */ function getAllExampleFiles(): { skill: string; reference: string; file: string; fullPath: string }[] { const results: { skill: string; reference: string; file: string; fullPath: string }[] = []; const entries = fs.readdirSync(CATALOG_DIR).filter((f) => { @@ -191,18 +235,28 @@ function getAllExampleFiles(): { skill: string; reference: string; file: string; for (const entry of entries) { const examplesDir = path.join(CATALOG_DIR, entry, 'examples'); if (!fs.existsSync(examplesDir)) continue; - const refDirs = fs.readdirSync(examplesDir).filter((f) => { - return fs.statSync(path.join(examplesDir, f)).isDirectory(); - }); - for (const refDir of refDirs) { - const refPath = path.join(examplesDir, refDir); - const files = fs.readdirSync(refPath).filter((f) => f.endsWith('.md')); - for (const file of files) { + const items = fs.readdirSync(examplesDir); + for (const item of items) { + const itemPath = path.join(examplesDir, item); + const stat = fs.statSync(itemPath); + if (stat.isDirectory()) { + // Router layout: examples grouped under references. + const files = fs.readdirSync(itemPath).filter((f) => f.endsWith('.md')); + for (const file of files) { + results.push({ + skill: entry, + reference: item, + file, + fullPath: path.join(itemPath, file), + }); + } + } else if (stat.isFile() && item.endsWith('.md')) { + // Component layout: flat examples directly under examples/. results.push({ skill: entry, - reference: refDir, - file, - fullPath: path.join(refPath, file), + reference: '_top', + file: item, + fullPath: itemPath, }); } } @@ -210,6 +264,31 @@ function getAllExampleFiles(): { skill: string; reference: string; file: string; return results; } +/** + * Return every rule file for component-layout skills: + * `rules/.md`. Router-layout skills don't have a `rules/` directory. + */ +function getAllRuleFiles(): { 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 rulesDir = path.join(CATALOG_DIR, entry, 'rules'); + if (!fs.existsSync(rulesDir)) continue; + const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md')); + for (const file of files) { + results.push({ + skill: entry, + file, + fullPath: path.join(rulesDir, file), + }); + } + } + return results; +} + describe('skills catalog validation', () => { let manifest: SkillManifest; let skillDirs: string[]; @@ -219,6 +298,13 @@ describe('skills catalog validation', () => { skillDirs = findAllSkillDirs(); }); + beforeEach(() => { + // Watch-mode safety: the manifest can be edited between runs (especially + // when iterating on a component-layout skill); reset the cached layout + // map so each describe block sees the freshly-loaded layout assignments. + resetSkillLayoutCache(); + }); + describe('manifest structure', () => { it('should have version 1', () => { expect(manifest.version).toBe(1); @@ -424,6 +510,11 @@ describe('skills catalog validation', () => { it('manifest descriptions should match SKILL.md frontmatter descriptions', () => { const mismatches: string[] = []; for (const entry of manifest.skills) { + // Component-layout skills intentionally use a rich, multi-line + // SKILL.md `description:` block (triggers + when-to-use prose, tuned + // for Claude Code's auto-discovery) and a shorter listing-friendly + // description in the manifest. They are not expected to match. + if (entry.layout === 'component') continue; 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; @@ -440,7 +531,9 @@ describe('skills catalog validation', () => { ...getAllReferenceFiles(), ...getAllExampleFiles().map(({ skill, file, fullPath, reference }) => ({ skill, - file: `examples/${reference}/${file}`, + // Router-layout examples live under examples//; component-layout + // examples are flat under examples/ (reference === '_top'). + file: reference === '_top' ? `examples/${file}` : `examples/${reference}/${file}`, fullPath, })), ]; @@ -525,13 +618,16 @@ describe('skills catalog validation', () => { }); describe('examples validation', () => { - it('every examples/ subfolder should match a reference filename', () => { + it('every examples/ subfolder should match a reference filename (router layout only)', () => { const mismatches: 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) { + // Component-layout skills use a flat examples/ directory — there are + // no subfolders to match against references. Skip them entirely. + if (isComponentLayout(entry)) continue; const examplesDir = path.join(CATALOG_DIR, entry, 'examples'); if (!fs.existsSync(examplesDir)) continue; const refsDir = path.join(CATALOG_DIR, entry, 'references'); @@ -556,34 +652,43 @@ describe('skills catalog validation', () => { it('every example .md file should have valid frontmatter', () => { const invalid: string[] = []; for (const { skill, reference, file, fullPath } of getAllExampleFiles()) { + const isComponent = reference === '_top'; const content = fs.readFileSync(fullPath, 'utf-8'); const { frontmatter } = parseSkillMdFrontmatter(content); const expectedName = file.replace(/\.md$/, ''); + const locDisplay = isComponent ? `${skill}/examples/${file}` : `${skill}/examples/${reference}/${file}`; if (!frontmatter['name'] || typeof frontmatter['name'] !== 'string') { - invalid.push(`${skill}/examples/${reference}/${file}: missing or invalid "name" in frontmatter`); + invalid.push(`${locDisplay}: missing or invalid "name" in frontmatter`); } if (frontmatter['name'] && frontmatter['name'] !== expectedName) { - invalid.push( - `${skill}/examples/${reference}/${file}: frontmatter "name" must match filename "${expectedName}"`, - ); + invalid.push(`${locDisplay}: frontmatter "name" must match filename "${expectedName}"`); } - if (!frontmatter['reference'] || typeof frontmatter['reference'] !== 'string') { - invalid.push(`${skill}/examples/${reference}/${file}: missing or invalid "reference" in frontmatter`); + // Router-layout examples are grouped under a parent reference and MUST + // declare it. Component-layout examples are flat and do not have one. + if (!isComponent) { + if (!frontmatter['reference'] || typeof frontmatter['reference'] !== 'string') { + invalid.push(`${locDisplay}: missing or invalid "reference" in frontmatter`); + } + if (frontmatter['reference'] && frontmatter['reference'] !== reference) { + invalid.push( + `${locDisplay}: frontmatter "reference" is "${frontmatter['reference']}" but expected "${reference}"`, + ); + } } if ( !frontmatter['level'] || !(VALID_EXAMPLE_LEVELS as readonly string[]).includes(frontmatter['level'] as string) ) { invalid.push( - `${skill}/examples/${reference}/${file}: missing or invalid "level" in frontmatter (must be ${VALID_EXAMPLE_LEVELS.join(', ')})`, + `${locDisplay}: missing or invalid "level" in frontmatter (must be ${VALID_EXAMPLE_LEVELS.join(', ')})`, ); } if (!frontmatter['description'] || typeof frontmatter['description'] !== 'string') { - invalid.push(`${skill}/examples/${reference}/${file}: missing or invalid "description" in frontmatter`); + invalid.push(`${locDisplay}: missing or invalid "description" in frontmatter`); } const tags = frontmatter['tags']; if (!Array.isArray(tags) || tags.length === 0 || tags.some((tag) => typeof tag !== 'string' || !tag.trim())) { - invalid.push(`${skill}/examples/${reference}/${file}: missing or invalid "tags" in frontmatter`); + invalid.push(`${locDisplay}: missing or invalid "tags" in frontmatter`); } const features = frontmatter['features']; if ( @@ -591,13 +696,7 @@ describe('skills catalog validation', () => { features.length === 0 || features.some((feature) => typeof feature !== 'string' || !feature.trim()) ) { - invalid.push(`${skill}/examples/${reference}/${file}: missing or invalid "features" in frontmatter`); - } - // reference field should match the parent directory name - if (frontmatter['reference'] && frontmatter['reference'] !== reference) { - invalid.push( - `${skill}/examples/${reference}/${file}: frontmatter "reference" is "${frontmatter['reference']}" but expected "${reference}"`, - ); + invalid.push(`${locDisplay}: missing or invalid "features" in frontmatter`); } } expect(invalid).toEqual([]); @@ -606,6 +705,8 @@ describe('skills catalog validation', () => { it('example frontmatter should stay aligned with the example body', () => { const mismatches: string[] = []; for (const { skill, reference, file, fullPath } of getAllExampleFiles()) { + const isComponent = reference === '_top'; + const locDisplay = isComponent ? `${skill}/examples/${file}` : `${skill}/examples/${reference}/${file}`; const content = fs.readFileSync(fullPath, 'utf-8'); const { frontmatter } = parseSkillMdFrontmatter(content); const description = typeof frontmatter['description'] === 'string' ? frontmatter['description'] : ''; @@ -616,14 +717,10 @@ describe('skills catalog validation', () => { const whatThisDemonstrates = extractSectionBullets(content, 'What This Demonstrates'); if (description !== firstParagraph) { - mismatches.push( - `${skill}/examples/${reference}/${file}: frontmatter "description" must match the first paragraph after the H1`, - ); + mismatches.push(`${locDisplay}: frontmatter "description" must match the first paragraph after the H1`); } if (JSON.stringify(features) !== JSON.stringify(whatThisDemonstrates)) { - mismatches.push( - `${skill}/examples/${reference}/${file}: frontmatter "features" must match the "What This Demonstrates" bullets`, - ); + mismatches.push(`${locDisplay}: frontmatter "features" must match the "What This Demonstrates" bullets`); } } expect(mismatches).toEqual([]); @@ -631,7 +728,10 @@ describe('skills catalog validation', () => { it('manifest example entries should match example file metadata', () => { const mismatches: string[] = []; + // Router-layout key: "//"; component-layout key: "/_top/". const manifestExampleKeys = new Set(); + + // Router-layout entries (entry.references[].examples[]). for (const entry of manifest.skills) { if (!entry.references) continue; for (const ref of entry.references) { @@ -682,6 +782,53 @@ describe('skills catalog validation', () => { } } + // Component-layout entries (entry.examples[] at the top level). + for (const entry of manifest.skills) { + if (entry.layout !== 'component') continue; + const examples = entry.examples ?? []; + const examplesDir = path.join(CATALOG_DIR, entry.path, 'examples'); + for (const example of examples) { + manifestExampleKeys.add(`${entry.path}/_top/${example.name}`); + const exampleFile = path.join(examplesDir, `${example.name}.md`); + if (!fs.existsSync(exampleFile)) { + mismatches.push(`${entry.name}/${example.name}.md listed in manifest but missing on disk`); + continue; + } + if (!(VALID_EXAMPLE_LEVELS as readonly string[]).includes(example.level)) { + mismatches.push(`${entry.name}/${example.name} has invalid level "${example.level}"`); + } + if (!Array.isArray(example.tags) || example.tags.length === 0) { + mismatches.push(`${entry.name}/${example.name} has invalid manifest tags`); + } + if (!Array.isArray(example.features) || example.features.length === 0) { + mismatches.push(`${entry.name}/${example.name} has invalid manifest features`); + } + + const { frontmatter } = parseSkillMdFrontmatter(fs.readFileSync(exampleFile, 'utf-8')); + const fileDescription = typeof frontmatter['description'] === 'string' ? frontmatter['description'] : ''; + const fileLevel = typeof frontmatter['level'] === 'string' ? frontmatter['level'] : ''; + const fileTags = Array.isArray(frontmatter['tags']) + ? frontmatter['tags'].filter((tag): tag is string => typeof tag === 'string') + : []; + const fileFeatures = Array.isArray(frontmatter['features']) + ? frontmatter['features'].filter((feature): feature is string => typeof feature === 'string') + : []; + + if (example.description !== fileDescription) { + mismatches.push(`${entry.name}/${example.name}: manifest description differs from example file`); + } + if (example.level !== fileLevel) { + mismatches.push(`${entry.name}/${example.name}: manifest level differs from example file`); + } + if (JSON.stringify(example.tags) !== JSON.stringify(fileTags)) { + mismatches.push(`${entry.name}/${example.name}: manifest tags differ from example file`); + } + if (JSON.stringify(example.features) !== JSON.stringify(fileFeatures)) { + mismatches.push(`${entry.name}/${example.name}: manifest features differ from example file`); + } + } + } + for (const { skill, reference, file } of getAllExampleFiles()) { const exampleName = file.replace(/\.md$/, ''); const key = `${skill}/${reference}/${exampleName}`; @@ -693,6 +840,87 @@ describe('skills catalog validation', () => { expect(mismatches).toEqual([]); }); + it('component-layout rule entries should match rule file frontmatter', () => { + const mismatches: string[] = []; + const manifestRuleKeys = new Set(); + + for (const entry of manifest.skills) { + if (entry.layout !== 'component') continue; + const rules = entry.rules ?? []; + const rulesDir = path.join(CATALOG_DIR, entry.path, 'rules'); + for (const rule of rules) { + manifestRuleKeys.add(`${entry.path}/${rule.name}`); + const ruleFile = path.join(rulesDir, `${rule.name}.md`); + if (!fs.existsSync(ruleFile)) { + mismatches.push(`${entry.name}/${rule.name}.md listed in manifest but missing on disk`); + continue; + } + if (!rule.constraint || typeof rule.constraint !== 'string') { + mismatches.push(`${entry.name}/${rule.name}: manifest "constraint" must be a non-empty string`); + } + if (rule.severity && !['required', 'recommended'].includes(rule.severity)) { + mismatches.push(`${entry.name}/${rule.name}: manifest "severity" must be 'required' or 'recommended'`); + } + + const { frontmatter } = parseSkillMdFrontmatter(fs.readFileSync(ruleFile, 'utf-8')); + const fileName = typeof frontmatter['name'] === 'string' ? frontmatter['name'] : ''; + const fileConstraint = typeof frontmatter['constraint'] === 'string' ? frontmatter['constraint'] : ''; + const fileSeverity = typeof frontmatter['severity'] === 'string' ? frontmatter['severity'] : undefined; + + if (fileName && fileName !== rule.name) { + mismatches.push( + `${entry.name}/${rule.name}: rule file frontmatter "name" is "${fileName}" but expected "${rule.name}"`, + ); + } + if (rule.constraint !== fileConstraint) { + mismatches.push(`${entry.name}/${rule.name}: manifest constraint differs from rule file`); + } + if (fileSeverity && fileSeverity !== (rule.severity ?? 'required')) { + mismatches.push( + `${entry.name}/${rule.name}: rule file severity "${fileSeverity}" differs from manifest "${rule.severity ?? 'required'}"`, + ); + } + } + } + + for (const { skill, file } of getAllRuleFiles()) { + const ruleName = file.replace(/\.md$/, ''); + const key = `${skill}/${ruleName}`; + if (!manifestRuleKeys.has(key)) { + mismatches.push(`${key}.md exists on disk but is missing from the manifest rules[]`); + } + } + + expect(mismatches).toEqual([]); + }); + + it('layout-specific manifest fields should be populated consistently', () => { + const issues: string[] = []; + for (const entry of manifest.skills) { + const layout = entry.layout ?? 'router'; + if (layout === 'component') { + if (!entry.examples || entry.examples.length === 0) { + issues.push(`${entry.name}: layout 'component' requires a non-empty top-level examples[]`); + } + if (!entry.rules || entry.rules.length === 0) { + issues.push( + `${entry.name}: layout 'component' should declare rules[] (component skills exist to bundle DO/DON'T constraints)`, + ); + } + } else { + if (entry.examples && entry.examples.length > 0) { + issues.push( + `${entry.name}: layout 'router' must not declare top-level examples[] — group examples under references[].examples[] instead`, + ); + } + if (entry.rules && entry.rules.length > 0) { + issues.push(`${entry.name}: layout 'router' must not declare top-level rules[]`); + } + } + } + expect(issues).toEqual([]); + }); + it('reference example tables should match manifest example metadata', () => { const mismatches: string[] = []; for (const entry of manifest.skills) { @@ -833,6 +1061,11 @@ describe('skills catalog validation', () => { it.each(findAllSkillDirs().map((d) => [d]))( '"%s" body contains the canonical sections required by TEMPLATE.md', (dir) => { + // Component-layout skills use a different structure (rich frontmatter, + // Decision Tree / Scenario Routing Table / Inherited Defaults / Rules + // sections instead of the TEMPLATE.md headings). The structural + // expectations for them are encoded below in a separate it.each block. + if (isComponentLayout(dir)) return; const body = readSkillFile(dir); // Each catalog skill must, at minimum, have these top-level // sections so consumers see a uniform structure regardless of @@ -851,17 +1084,47 @@ describe('skills catalog validation', () => { }, ); + it.each(findAllSkillDirs().map((d) => [d]))( + '"%s" component-layout skills carry the rich-frontmatter shape', + (dir) => { + if (!isComponentLayout(dir)) return; + const body = readSkillFile(dir); + const { frontmatter } = parseSkillMdFrontmatter(body); + // These are the auto-trigger hooks Claude Code reads. Component + // skills must declare all of them — that's the whole point of the + // new layout. + expect(typeof frontmatter['description']).toBe('string'); + expect((frontmatter['description'] as string).length).toBeGreaterThan(80); + expect(typeof frontmatter['when_to_use']).toBe('string'); + expect(typeof frontmatter['paths']).toBe('string'); + expect(frontmatter['layout']).toBe('component'); + // Body still needs a few human-readable sections so the skill is + // skim-able by a developer, but the section names are intentionally + // freer than the legacy TEMPLATE.md set. + for (const heading of ['## Decision tree', '## Scenario routing table', '## References', '## Rules']) { + expect(body).toContain(heading); + } + }, + ); + it.each(findAllSkillDirs().map((d) => [d]))( '"%s" Accessing This Skill section names the skill correctly', (dir) => { + // Component skills use a lower-cased `## Accessing this skill` + // heading; assertion below is case-insensitive for that branch. const body = readSkillFile(dir); const { frontmatter } = parseSkillMdFrontmatter(body); const name = frontmatter['name']; if (typeof name !== 'string') return; - // The section is generated per skill — the literal name should - // appear in the URI examples it documents. - const idx = body.indexOf('## Accessing This Skill'); + const headingMatch = isComponentLayout(dir) + ? body.match(/^## Accessing this skill$/im) + : body.match(/^## Accessing This Skill$/m); + expect(headingMatch).not.toBeNull(); + if (headingMatch === null) { + throw new Error(`Missing "Accessing This Skill" heading for ${dir}`); + } + const idx = body.indexOf(headingMatch[0]); expect(idx).toBeGreaterThanOrEqual(0); const section = body.slice(idx); const nextHeading = section.indexOf('\n## ', 1); diff --git a/libs/skills/catalog/TEMPLATE.md b/libs/skills/catalog/TEMPLATE.md index 55bc251b7..25e238ded 100644 --- a/libs/skills/catalog/TEMPLATE.md +++ b/libs/skills/catalog/TEMPLATE.md @@ -166,3 +166,29 @@ frontmatter `name`. - [Documentation](https://docs.agentfront.dev/frontmcp/...) - Related skills: `related-skill-a`, `related-skill-b` + +--- + +## Alternative: `layout: 'component'` + +The template above describes the **router layout** (`layout: 'router'`, the default) — a Scenario Routing Table SKILL.md, `references/.md` files, and examples grouped under `examples//.md`. + +For per-thing skills (`create-tool`, `create-resource`, `create-prompt`, etc.) use the **component layout** instead. Opt in by setting `layout: component` in the manifest entry. Differences: + +| Aspect | Router layout (default) | Component layout | +| -------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SKILL.md frontmatter | Minimal — `name`, `description`, optional `priority`/`visibility` | **Rich** — multi-line `description:` with an explicit `Triggers:` list, `paths:` glob array, `when_to_use` block, top-level `priority`/`visibility`/`tags`/`category`. Designed for Claude Code's auto-discovery heuristics. | +| `examples/` | Grouped: `examples//.md` | Flat: `examples/.md` | +| `rules/` | Not used | `rules/.md` — short DO/DON'T constraint files with `name`, `constraint`, `severity: required \| recommended` frontmatter | +| Manifest entry | `references[].examples[]` | Top-level `examples[]` + top-level `rules[]` | +| SKILL.md body | "Scenario Routing Table" pointing at references | "Scenario Routing Table" pointing at examples + a `Rules` table pointing at `rules/*.md` | + +Every example file MUST still satisfy the same alignment invariants enforced by `skills-validation.spec.ts`: + +- Frontmatter `description` = first paragraph after the H1. +- Frontmatter `features` = bullets under `## What This Demonstrates`. +- Manifest example entry `description` / `level` / `tags` / `features` = the file's frontmatter. + +For component-layout skills the manifest sync extends to `rules[]`: the rule file's frontmatter `constraint` and `severity` must match the manifest entry. + +See `create-tool/SKILL.md` for a complete working example of the component layout. diff --git a/libs/skills/catalog/create-tool/SKILL.md b/libs/skills/catalog/create-tool/SKILL.md new file mode 100644 index 000000000..0e2a7d02b --- /dev/null +++ b/libs/skills/catalog/create-tool/SKILL.md @@ -0,0 +1,318 @@ +--- +name: create-tool +description: | + ALWAYS use this skill when the user asks to build, modify, or audit a FrontMCP tool. + Covers everything inside `@Tool({...})`: class and function-style tools, Zod input/output + schemas with derived `execute()` types, dependency injection (`this.get` / `this.tryGet`), + error handling (`this.fail`, MCP error classes), throttling (rate-limit / concurrency / + timeout), auth providers (single / multi / vault), availability constraints + (`availableWhen`), elicitation (`this.elicit`), interactive UI widgets via `@Tool({ ui })` + (MCP Apps / SEP-1865 — including `.tsx` FileSource, CSP, `window.FrontMcpBridge`, + host-detect `resourceMode`), annotations (`readOnlyHint` / `destructiveHint` / …), + `examples` metadata, registration in `@App({ tools })`, and per-tool unit testing. + + Does NOT cover: + - Read-only data exposed via a URI — use `create-resource` + - Conversation templates / system prompts — use `create-prompt` + - Multi-tool orchestration loops — use `create-agent` + - Background work / pipelines — use `create-job` / `create-workflow` + - Server-level config (transport, sessions, auth modes) — use `config` / `auth` + + Triggers: `@Tool`, ToolContext, tool decorator, MCP tool, snake_case tool name, + inputSchema, outputSchema, ToolInputOf, ToolOutputOf, `@Tool({ ui })`, tool UI widget, + MCP Apps widget, FileSource widget, `.tsx` widget, ui.csp, ui.resourceMode, + window.FrontMcpBridge, tool annotations, readOnlyHint, destructiveHint, rate-limit tool, + throttle tool, concurrency tool, tool timeout, this.fail, this.respond, this.fetch, + this.notify, this.progress, this.elicit, ElicitationDisabledError, ToolContext.execute, + this.get(TOKEN), this.tryGet, register tool in @App, tool examples metadata, + availableWhen, missingAxes, `tool()` function builder, Tool.esm, Tool.remote, + PublicMcpError, ResourceNotFoundError, MCP_ERROR_CODES, ui://widget. + +when_to_use: | + Trigger when creating or editing a `*.tool.ts` / `*.tool.tsx` file, adding a `@Tool` + decorator, defining `inputSchema` / `outputSchema` for a tool, deriving `execute()` + parameter or return types, wiring dependency injection into a tool, returning + structured / media / resource content, adding a `ui:` block (HTML / MDX / React / + FileSource), configuring throttling, declaring auth providers, restricting platforms + via `availableWhen`, requesting interactive input via `this.elicit`, adding tool + `annotations`, or registering a tool in `@App({ tools })`. + +paths: '**/*.tool.ts, **/*.tool.tsx, **/tools/**/*.ts, **/apps/*/tools/**, **/*.tool.spec.ts' + +layout: component +license: Apache-2.0 +priority: 10 +visibility: public +tags: + [ + development, + tool, + create-tool, + decorator, + input-schema, + output-schema, + ToolContext, + ui, + mcp-apps, + annotations, + throttling, + auth-providers, + availability, + elicitation, + ] +category: development/create +bundle: [recommended, minimal, full] +allowed-tools: Read Edit Write Grep Glob Bash + +metadata: + docs: https://docs.agentfront.dev/frontmcp/servers/tools +--- + +# Create a FrontMCP Tool + +Tools are the primary way to expose executable actions to AI clients in the MCP protocol. In FrontMCP, every tool is a TypeScript class that extends `ToolContext`, decorated with `@Tool({...})`, and registered on an `@App` (or directly on `@FrontMcp` for simple servers). + +This skill is the single source of truth for building tools. It owns: + +- The `@Tool` decorator surface +- Input / output schemas and how to derive `execute()` types from them +- Dependency injection, error handling, progress / notifications +- Throttling: rate-limit, concurrency, timeout +- Auth providers and the credential vault +- Platform / runtime / surface availability constraints +- Elicitation (interactive input mid-execution) +- **Tool UI widgets** — the `ui:` block, MCP Apps / SEP-1865, `.tsx` FileSource, CSP, `window.FrontMcpBridge` +- Annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) +- The `examples` metadata field +- Function-style tools, remote / ESM tools +- Registration patterns +- Per-tool unit testing + +For everything else — resources, prompts, agents, jobs, workflows, adapters, plugins, providers, channels — use the matching `create-` skill. + +> **First time?** Start with [`references/quick-start.md`](./references/quick-start.md), then jump to the example matching your scenario via the [Decision Tree](#decision-tree) below. + +--- + +## Inherited defaults + +This skill ALWAYS applies these defaults — never opt out without an audited reason: + +| Default | Source | What it enforces | +| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **`inputSchema` is a Zod raw shape** | [`rules/input-schema-is-raw-shape.md`](./rules/input-schema-is-raw-shape.md) | Plain object mapping field → Zod type. Framework wraps internally. Never `z.object(...)` at the top level. | +| **`outputSchema` is always defined** | [`rules/always-define-output-schema.md`](./rules/always-define-output-schema.md) | Prevents data leaks, enables CodeCall chaining, gives compile-time type safety. | +| **`execute()` types are derived from the schemas** | [`rules/derive-execute-types.md`](./rules/derive-execute-types.md) | `ToolInputOf<>` / `ToolOutputOf<>` over the hoisted schemas. Schema is the single source of truth. | +| **`class MyTool extends ToolContext`** — no generics | [`rules/no-toolcontext-generics.md`](./rules/no-toolcontext-generics.md) | Types are auto-inferred from `@Tool`. Explicit generics are redundant and forbidden. | +| **Tool names are `snake_case`** | [`rules/snake-case-tool-names.md`](./rules/snake-case-tool-names.md) | MCP protocol convention. `get_weather`, not `getWeather`. | +| **No `try/catch` around `execute()`** | [`rules/no-try-catch-around-execute.md`](./rules/no-try-catch-around-execute.md) | The framework's flow catches and formats errors. Wrapping defeats it. | +| **`this.fail(new McpError(…))` for business errors** | [`rules/use-this-fail-for-business-errors.md`](./rules/use-this-fail-for-business-errors.md) | Triggers the error flow with proper JSON-RPC codes. Raw `throw` skips it. | +| **Register tools in `@App({ tools })`** | [`rules/register-in-app.md`](./rules/register-in-app.md) | Apps own modularity and lifecycle. Top-level `@FrontMcp({ tools })` is the simple-server escape hatch. | +| **`.tsx` widget paths use `fileURLToPath(new URL('./x.tsx', import.meta.url))`** | [`rules/widget-paths-anchor-with-import-meta-url.md`](./rules/widget-paths-anchor-with-import-meta-url.md) | Relative `FileSource` paths resolve against `process.cwd()` — the workaround is mandatory (issue #444). | +| **Leave `ui.resourceMode` unset by default** | [`rules/widget-resource-mode-host-detect.md`](./rules/widget-resource-mode-host-detect.md) | The framework host-detects: Claude → `'inline'`, others → `'cdn'` (issue #456). Set explicitly only to override. | + +If a request seems to conflict with an inherited default (e.g., "wrap `inputSchema` in `z.object` to use refinements", or "use `try/catch` to swallow upstream errors"), **stop and ask** — never silently override. + +--- + +## When to invoke this skill + +### Must use + +- Creating a new `*.tool.ts` file +- Adding the `@Tool({...})` decorator +- Defining or changing `inputSchema` / `outputSchema` +- Adding a `ui:` block to a tool (any template type) +- Adding `annotations`, `rateLimit`, `concurrency`, `timeout`, `authProviders`, `availableWhen`, `examples` to a tool +- Calling `this.elicit(...)` from `execute()` +- Registering a tool in `@App({ tools })` or `@FrontMcp({ tools })` +- Writing the unit test for a tool + +### Recommended + +- Auditing an existing tool for the inherited defaults above +- Picking between class-style and function-style (`tool({...})(handler)`) +- Choosing the right output-schema variant for the data you're returning +- Converting a tool's auth from a single string to the full `{ name, scopes, required }` mapping +- Deciding whether a side-effecting tool needs `destructiveHint: true` + +### Skip when + +- You're not building a tool. Use the matching `create-` skill. + +--- + +## Decision tree + +```text +1. What kind of tool? + ├── Tiny one-off → function-style: `tool({...})((input, ctx) => …)` + │ See: examples/02-basic-function-tool.md + ├── Anything with DI, lifecycle, hooks, or UI → class-style + │ See: examples/01-basic-class-tool.md + └── Externally hosted (ESM URL or remote MCP server) → Tool.esm / Tool.remote + See: references/remote-and-esm.md + +2. What does it return? + ├── Structured JSON → outputSchema: { field: z.string(), … } + │ See: examples/03-tool-with-zod-shape-output.md + ├── A primitive (text/num) → outputSchema: 'string' | 'number' | 'boolean' | 'date' + │ See: examples/05-tool-with-primitive-output.md + ├── Media (image/audio) → outputSchema: 'image' | 'audio' + │ See: examples/06-tool-with-media-output.md + ├── A resource link → outputSchema: 'resource' | 'resource_link' + │ See: examples/26-tool-with-resource-link-output.md + └── Several content blocks → outputSchema: ['string', 'image'] + See: examples/06-tool-with-media-output.md + +3. Does it need shared services / config / clients? + YES → register a @Provider; inject via this.get(TOKEN) + See: examples/08-tool-with-provider-injection.md + +4. Does it call an external HTTP API? + YES → use this.fetch(input, init?) (context propagation) + See: examples/11-tool-with-fetch.md + +5. Does it need user credentials from an OAuth provider? + YES → declare authProviders: ['provider'] (or full mapping) + See: examples/13-tool-with-single-auth-provider.md, 15-tool-with-credential-vault.md + +6. Is it expensive / rate-limited / slow? + YES → add rateLimit / concurrency / timeout + See: examples/16-tool-with-rate-limit.md, 17-tool-with-concurrency-and-timeout.md + +7. Does it run for a while? Want progress? + YES → call this.progress(n, total, msg) + See: examples/18-tool-with-progress-and-notify.md + +8. Does it need a confirmation / extra input mid-run? + YES → this.elicit('msg', { fieldSchema }) + See: examples/19-tool-with-elicitation.md + +9. Is it destructive / read-only / idempotent / open-world? + YES → annotations: { destructiveHint, readOnlyHint, idempotentHint, openWorldHint } + See: examples/20-tool-with-annotations.md + +10. Should it only run on certain OSes / runtimes / build targets? + YES → availableWhen: { os, runtime, deployment, provider, target, surface, env } + See: examples/21-tool-with-availability-constraints.md + +11. Should the result render as a widget in the host UI? + YES → ui: { template, … } + ├── Quick HTML → ui: { template: (ctx) => '
' } + │ See: examples/22-tool-with-ui-html-template.md + ├── React widget (file) → ui: { template: { file: widgetPath } } + │ See: examples/23-tool-with-ui-filesource-tsx.md + ├── Calls other tools → widgetAccessible: true + window.FrontMcpBridge + │ See: examples/24-tool-with-ui-csp-and-bridge.md + └── Claude target → resourceMode is auto-detected; do not set + See: references/ui-widgets.md + +12. Does it hand off long work to a job? + YES → kick off a job + return a tracking handle + See: examples/25-tool-handing-off-to-job.md +``` + +--- + +## Scenario routing table + +| Scenario | Example | Why | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| Build the simplest possible tool | [`01-basic-class-tool`](./examples/01-basic-class-tool.md) | Foundation — every other example builds on this shape | +| One-off math / formatter | [`02-basic-function-tool`](./examples/02-basic-function-tool.md) | `tool()` builder is fine for trivial pure-input tools | +| Return structured JSON | [`03-tool-with-zod-shape-output`](./examples/03-tool-with-zod-shape-output.md) | Raw Zod shape — recommended for any complex output | +| Output is a complex Zod schema | [`04-tool-with-zod-schema-output`](./examples/04-tool-with-zod-schema-output.md) | `z.object()` / `z.array()` / `z.discriminatedUnion()` for full Zod | +| Output is a primitive | [`05-tool-with-primitive-output`](./examples/05-tool-with-primitive-output.md) | `'string'` / `'number'` / `'date'` literals | +| Output is binary / multi-content | [`06-tool-with-media-output`](./examples/06-tool-with-media-output.md) | `'image'`, `'audio'`, `['string', 'image']` | +| Tool resolves dependencies via DI | [`08-tool-with-provider-injection`](./examples/08-tool-with-provider-injection.md) | `this.get(TOKEN)` against a `@Provider`-registered service | +| Tool composes multiple services | [`09-tool-with-multiple-providers`](./examples/09-tool-with-multiple-providers.md) | Realistic shape — DB + cache + config in one tool | +| Tool calls an external HTTP API | [`11-tool-with-fetch`](./examples/11-tool-with-fetch.md) | `this.fetch(url, init?)` — context propagation, error handling | +| Tool calls a flaky API with retries | [`12-tool-with-fetch-and-retries`](./examples/12-tool-with-fetch-and-retries.md) | Exponential backoff, idempotency-key, retry config | +| Tool needs OAuth credentials | [`13-tool-with-single-auth-provider`](./examples/13-tool-with-single-auth-provider.md) | `authProviders: ['github']` — string shorthand | +| Tool needs scoped / optional creds | [`14-tool-with-multiple-auth-providers`](./examples/14-tool-with-multiple-auth-providers.md) | Full mapping form with `required` + `scopes` + `alias` | +| Tool reads a per-session secret | [`15-tool-with-credential-vault`](./examples/15-tool-with-credential-vault.md) | `this.authProviders.headers(...)`, vault patterns | +| Rate-limit an expensive operation | [`16-tool-with-rate-limit`](./examples/16-tool-with-rate-limit.md) | `rateLimit: { maxRequests, windowMs }` | +| Cap concurrency + add a timeout | [`17-tool-with-concurrency-and-timeout`](./examples/17-tool-with-concurrency-and-timeout.md) | Production-ready throttling shape | +| Long-running tool with progress | [`18-tool-with-progress-and-notify`](./examples/18-tool-with-progress-and-notify.md) | `this.progress` + `this.notify` + `this.mark` | +| Tool that asks the user mid-run | [`19-tool-with-elicitation`](./examples/19-tool-with-elicitation.md) | `this.elicit` with Zod schema | +| Tool with behavioral hints for the client | [`20-tool-with-annotations`](./examples/20-tool-with-annotations.md) | `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` | +| Tool restricted to one OS / runtime / target | [`21-tool-with-availability-constraints`](./examples/21-tool-with-availability-constraints.md) | `availableWhen` axes | +| Tool with a quick inline HTML widget | [`22-tool-with-ui-html-template`](./examples/22-tool-with-ui-html-template.md) | `ui: { template: (ctx) => '
' }` | +| Tool with a separate `.tsx` widget file | [`23-tool-with-ui-filesource-tsx`](./examples/23-tool-with-ui-filesource-tsx.md) | `FileSource` + `import.meta.url` anchoring | +| Tool widget that calls other tools | [`24-tool-with-ui-csp-and-bridge`](./examples/24-tool-with-ui-csp-and-bridge.md) | `widgetAccessible: true` + `window.FrontMcpBridge.callTool` | +| Tool that triggers a job + tracks it | [`25-tool-handing-off-to-job`](./examples/25-tool-handing-off-to-job.md) | Thin tool + heavy job — the right split | +| Tool that returns a resource handle | [`26-tool-with-resource-link-output`](./examples/26-tool-with-resource-link-output.md) | `outputSchema: 'resource_link'` — the host fetches the resource | +| Tool with `examples` metadata for discovery | [`27-tool-with-examples-metadata`](./examples/27-tool-with-examples-metadata.md) | `examples: [{ description, input, output? }]` | + +--- + +## Verification checklist + +Before considering a tool "done": + +- [ ] Class extends `ToolContext` (no generics) OR uses `tool()` function builder +- [ ] `@Tool({ name, description, inputSchema, outputSchema })` — all four present +- [ ] `name` is `snake_case` +- [ ] `inputSchema` is a Zod raw shape (NOT wrapped in `z.object`) +- [ ] `outputSchema` is defined (Zod shape / primitive / media / array) +- [ ] `execute()` parameter and return types derived via `ToolInputOf<>` / `ToolOutputOf<>` +- [ ] No `try/catch` around `execute()` body +- [ ] Business errors use `this.fail(new SomeMcpError(…))`, not raw `throw` +- [ ] Tool registered in an `@App({ tools })` (or `@FrontMcp({ tools })` for single-app servers) +- [ ] If `ui:`: `.tsx` widget paths anchored via `fileURLToPath(new URL(...))` +- [ ] If `ui:`: `ui.resourceMode` left unset (host-detect) unless an explicit override is intentional +- [ ] Unit test in `.tool.spec.ts` covering happy + at least one failure path +- [ ] Optional: `annotations`, `rateLimit` / `concurrency` / `timeout`, `authProviders`, `availableWhen`, `examples` set when the tool's behavior warrants them + +--- + +## References (deep dives) + +| Reference | Covers | +| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| [`quick-start.md`](./references/quick-start.md) | 60-second tour: minimal tool, registration, calling it from a test | +| [`decorator-options.md`](./references/decorator-options.md) | Every field on `@Tool({...})` — what it does, default, when to set it | +| [`input-schema.md`](./references/input-schema.md) | Raw shape vs `z.object`, refinements, defaults, optional, describe | +| [`output-schema.md`](./references/output-schema.md) | All supported output types: Zod shape, Zod schema, primitives, media, arrays | +| [`derived-types.md`](./references/derived-types.md) | `ToolInputOf` / `ToolOutputOf` patterns, file layout, schema hoisting | +| [`execution-context.md`](./references/execution-context.md) | `ToolContext` methods + properties — `this.get`, `this.fetch`, `this.notify`, `this.context`, etc. | +| [`error-handling.md`](./references/error-handling.md) | `this.fail`, MCP error classes (`PublicMcpError`, `ResourceNotFoundError`), error flow, when to throw vs `fail` | +| [`throttling.md`](./references/throttling.md) | `rateLimit`, `concurrency`, `timeout` — semantics, interaction, defaults | +| [`auth-providers.md`](./references/auth-providers.md) | `authProviders` string shorthand vs full mapping, scopes, alias, credential vault basics | +| [`availability.md`](./references/availability.md) | `availableWhen` axes (os / runtime / deployment / provider / target / surface / env), `missingAxes`, `isPlatform` | +| [`elicitation.md`](./references/elicitation.md) | `this.elicit`, server-level enable, `ElicitationDisabledError`, accept / decline / cancel | +| [`ui-widgets.md`](./references/ui-widgets.md) | `@Tool({ ui })` — template formats, `servingMode`, `resourceMode` host-detect, CSP, `widgetAccessible`, MCP Apps spec | +| [`annotations.md`](./references/annotations.md) | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title` | +| [`function-style-builder.md`](./references/function-style-builder.md) | `tool({...})(handler)` — when to pick over a class, register, ctx parameter | +| [`remote-and-esm.md`](./references/remote-and-esm.md) | `Tool.esm(...)` / `Tool.remote(...)` — load tools from ESM URLs or remote MCP servers | +| [`registration.md`](./references/registration.md) | `@App({ tools })` vs `@FrontMcp({ tools })`, multi-app composition | +| [`file-layout.md`](./references/file-layout.md) | Flat-sibling vs folder-per-tool, `.schema.ts` / `.tool.ts` / `.tool.spec.ts` | +| [`testing.md`](./references/testing.md) | Per-tool unit tests — `@frontmcp/testing`, mocking DI, asserting output validation | + +## Rules (constraints — read these once, then they're enforced) + +| Rule | Constraint | +| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [`input-schema-is-raw-shape.md`](./rules/input-schema-is-raw-shape.md) | `inputSchema` is a raw Zod shape, never `z.object(...)` | +| [`always-define-output-schema.md`](./rules/always-define-output-schema.md) | Every tool defines `outputSchema` | +| [`derive-execute-types.md`](./rules/derive-execute-types.md) | `execute()` types come from `ToolInputOf` / `ToolOutputOf` — never duplicated inline | +| [`no-toolcontext-generics.md`](./rules/no-toolcontext-generics.md) | `class MyTool extends ToolContext` — no `` generic | +| [`snake-case-tool-names.md`](./rules/snake-case-tool-names.md) | Tool `name` is `snake_case` | +| [`no-try-catch-around-execute.md`](./rules/no-try-catch-around-execute.md) | The framework owns error flow — don't wrap `execute()` body | +| [`use-this-fail-for-business-errors.md`](./rules/use-this-fail-for-business-errors.md) | `this.fail(new McpError(…))` — never raw `throw` for business errors | +| [`register-in-app.md`](./rules/register-in-app.md) | Register tools in `@App({ tools })` for modularity / lifecycle | +| [`widget-paths-anchor-with-import-meta-url.md`](./rules/widget-paths-anchor-with-import-meta-url.md) | `.tsx` widget paths via `fileURLToPath(new URL(...))` — never bare relative | +| [`widget-resource-mode-host-detect.md`](./rules/widget-resource-mode-host-detect.md) | Leave `ui.resourceMode` unset — let host detect | + +## Accessing this skill + +| Mode | How | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Filesystem** | Read `libs/skills/catalog/create-tool/` directly. `SKILL.md` is the entry point. | +| **CLI** | `frontmcp skills list`, `frontmcp skills read create-tool`, `frontmcp skills read create-tool:references/.md`, `frontmcp skills install create-tool` | +| **MCP `skill://`** | When mounted on a FrontMCP server, available at `skill://create-tool/SKILL.md`, `skill://create-tool/references/{file}.md`, etc. (SEP-2640) | + +## Related skills + +`create-resource`, `create-prompt`, `create-agent`, `create-provider`, `create-job`, `create-workflow`, `create-adapter`, `create-plugin`, `decorators-guide`, `architecture`, `testing`, `auth` diff --git a/libs/skills/catalog/create-tool/examples/01-basic-class-tool.md b/libs/skills/catalog/create-tool/examples/01-basic-class-tool.md new file mode 100644 index 000000000..42610edc0 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/01-basic-class-tool.md @@ -0,0 +1,112 @@ +--- +name: 01-basic-class-tool +level: basic +description: Minimal class-based tool with Zod input/output schemas and types derived from the schemas. The foundation every other example builds on. +tags: [foundation, class-tool, output-schema, derived-types] +features: + - 'Extending `ToolContext` (no generics) and implementing `execute()`' + - 'Hoisting `inputSchema` / `outputSchema` to a sibling `.schema.ts` file' + - 'Deriving the `execute()` parameter and return types via `ToolInputOf<>` / `ToolOutputOf<>`' + - 'Using a Zod raw shape for `inputSchema` (not `z.object(...)`)' + - "Always defining `outputSchema` so the tool can't accidentally leak extra fields" + - 'Registering the tool in an `@App({ tools })`' +--- + +# Basic Class Tool + +Minimal class-based tool with Zod input/output schemas and types derived from the schemas. The foundation every other example builds on. + +The foundation. Two files (schema + tool), schemas as the single source of truth, derived `execute()` types, full output validation. Every other example in this skill builds on this shape. + +## Files + +```text +src/apps/main/tools/ +├── greet-user.schema.ts # input/output schemas + derived types +├── greet-user.tool.ts # @Tool class, execute() +└── greet-user.tool.spec.ts # unit test +``` + +## Code + +```typescript +// src/apps/main/tools/greet-user.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { + name: z.string().min(1).describe('The name of the user to greet'), +}; + +export const outputSchema = { + greeting: z.string(), +}; + +export type GreetUserInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GreetUserOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/greet-user.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { inputSchema, outputSchema, type GreetUserInput, type GreetUserOutput } from './greet-user.schema'; + +@Tool({ + name: 'greet_user', + description: 'Greet a user by name', + inputSchema, + outputSchema, +}) +export class GreetUserTool extends ToolContext { + async execute(input: GreetUserInput): Promise { + return { greeting: `Hello, ${input.name}!` }; + } +} +``` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { GreetUserTool } from './tools/greet-user.tool'; + +@App({ + name: 'main', + tools: [GreetUserTool], +}) +export class MainApp {} +``` + +> **Testing.** Per-tool tests use the `@frontmcp/testing` surface — `TestServer` + Playwright-style `test` / `expect` fixtures, with `mcpMatchers` for response assertions. The full pattern lives in the dedicated `testing` skill; this skill stays focused on building the tool itself. + +## What This Demonstrates + +- Extending `ToolContext` (no generics) and implementing `execute()` +- Hoisting `inputSchema` / `outputSchema` to a sibling `.schema.ts` file +- Deriving the `execute()` parameter and return types via `ToolInputOf<>` / `ToolOutputOf<>` +- Using a Zod raw shape for `inputSchema` (not `z.object(...)`) +- Always defining `outputSchema` so the tool can't accidentally leak extra fields +- Registering the tool in an `@App({ tools })` + +## Why each choice matters + +- **No `ToolContext` generic** — input/output types are auto-inferred from the `@Tool` decorator at the class level. Adding the generic is a smell (see `rules/no-toolcontext-generics.md`). +- **Hoist only the schemas** (not the decorator config) to `.schema.ts` — specs, sibling tools, and generated clients can `import { inputSchema, GreetUserInput }` without dragging the `@Tool` class along. +- **Use `ToolInputOf<>` / `ToolOutputOf<>`** instead of inline annotations like `execute(input: { name: string })`, which silently drift when the schema changes. +- **Raw Zod shape** for `inputSchema` — `{ name: z.string() }`, not `z.object({ name: z.string() })`. The framework wraps it internally. +- **`outputSchema` always present** — without it, returning `{ greeting, leakedSecret }` would expose `leakedSecret` to the client; with it, the field is stripped before the response leaves. +- **Register in `@App({ tools })`** (not directly in `@FrontMcp({ tools })`) — apps provide per-app lifecycle, auth, and hooks; top-level registration is the simple-server escape hatch. + +## When to pick this shape over alternatives + +- **Class** (this example) vs **function** (`tool({...})(handler)`) — pick class for anything with DI (`this.get`), lifecycle, hooks, or UI widgets. Pick function only for trivial pure-input tools. See [`02-basic-function-tool`](./02-basic-function-tool.md). +- **Sibling files** (this example) vs **folder-per-tool** — pick siblings for apps with ≤3 tools each. Promote to a folder once the tool has local helpers, fixtures, or its own error types. See [`references/file-layout.md`](../references/file-layout.md). + +## Related rules + +- [`rules/input-schema-is-raw-shape.md`](../rules/input-schema-is-raw-shape.md) +- [`rules/always-define-output-schema.md`](../rules/always-define-output-schema.md) +- [`rules/derive-execute-types.md`](../rules/derive-execute-types.md) +- [`rules/no-toolcontext-generics.md`](../rules/no-toolcontext-generics.md) +- [`rules/snake-case-tool-names.md`](../rules/snake-case-tool-names.md) +- [`rules/register-in-app.md`](../rules/register-in-app.md) diff --git a/libs/skills/catalog/create-tool/examples/02-basic-function-tool.md b/libs/skills/catalog/create-tool/examples/02-basic-function-tool.md new file mode 100644 index 000000000..d63d41599 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/02-basic-function-tool.md @@ -0,0 +1,72 @@ +--- +name: 02-basic-function-tool +level: basic +description: 'Function-style `tool({...})(handler)` for a tiny pure-input tool — pick this over a class only when the tool needs no DI / lifecycle / UI.' +tags: [foundation, function-tool, tool-builder] +features: + - 'Using the `tool({...})(handler)` builder for a one-liner' + - "Returning a primitive via `outputSchema: 'number'`" + - 'Registering the function-style tool in `@App({ tools })` exactly like a class tool' + - "When function-style is the right choice (and when it isn't)" +--- + +# Basic Function Tool + +Function-style `tool({...})(handler)` for a tiny pure-input tool — pick this over a class only when the tool needs no DI / lifecycle / UI. + +For trivial pure-input tools, the `tool()` function builder is a one-liner alternative to `@Tool` + class. + +## Code + +```typescript +// src/apps/main/tools/add-numbers.tool.ts +import { tool, z } from '@frontmcp/sdk'; + +export 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) => input.a + input.b); +``` + +```typescript +// src/apps/main/tools/add-numbers.tool.spec.ts +import { testTool } from '@frontmcp/testing'; + +import { AddNumbers } from './add-numbers.tool'; + +describe('AddNumbers', () => { + it('adds two numbers', async () => { + expect(await testTool(AddNumbers).call({ a: 2, b: 3 })).toBe(5); + }); +}); +``` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { AddNumbers } from './tools/add-numbers.tool'; + +@App({ name: 'main', tools: [AddNumbers] }) +export class MainApp {} +``` + +## What This Demonstrates + +- Using the `tool({...})(handler)` builder for a one-liner +- Returning a primitive via `outputSchema: 'number'` +- Registering the function-style tool in `@App({ tools })` exactly like a class tool +- When function-style is the right choice (and when it isn't) + +## When to pick function-style over class + +- ✅ Pure math / formatting / parsing — no DI, no lifecycle, no UI widget +- ❌ Anything that needs `this.get(TOKEN)` — promote to class +- ❌ Anything with a `ui:` widget — class + folder-per-tool layout is cleaner + +See [`references/function-style-builder.md`](../references/function-style-builder.md) for the full `tool()` surface, including `(input, ctx)` handler form with `ctx.get` / `ctx.fail` / `ctx.fetch`. diff --git a/libs/skills/catalog/create-tool/examples/03-tool-with-zod-shape-output.md b/libs/skills/catalog/create-tool/examples/03-tool-with-zod-shape-output.md new file mode 100644 index 000000000..3ab48720b --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/03-tool-with-zod-shape-output.md @@ -0,0 +1,78 @@ +--- +name: 03-tool-with-zod-shape-output +level: basic +description: Tool returning structured JSON declared via a Zod raw shape outputSchema — the recommended pattern for any complex output. +tags: [output-schema, zod-shape, structured-output] +features: + - 'Declaring `outputSchema` as a Zod raw shape `{ field: z.string(), … }`' + - 'Constraining values with `.int().min(0)` so invalid output is rejected at the boundary' + - "Letting unrelated fields returned by the implementation (e.g. an upstream API's extras) be stripped silently" + - "Deriving `OrderSummaryOutput` once so the type and runtime contract can't drift" +--- + +# Tool With Zod Shape Output + +Tool returning structured JSON declared via a Zod raw shape outputSchema — the recommended pattern for any complex output. + +For structured JSON, declare `outputSchema` as a Zod raw shape — the same form as `inputSchema`. The shape is the runtime contract AND the source of the TypeScript output type. + +## Code + +```typescript +// src/apps/main/tools/order-summary.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { + orderId: z.string().uuid().describe('Order UUID'), +}; + +export const outputSchema = { + orderId: z.string(), + customer: z.string(), + totalUsd: z.number(), + itemCount: z.number().int().min(0), + pendingCount: z.number().int().min(0), + status: z.enum(['pending', 'paid', 'shipped', 'delivered', 'cancelled']), +}; + +export type OrderSummaryInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type OrderSummaryOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/order-summary.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { ORDERS_REPO } from '../tokens'; +import { inputSchema, outputSchema, type OrderSummaryInput, type OrderSummaryOutput } from './order-summary.schema'; + +@Tool({ + name: 'order_summary', + description: 'Summary for an order — totals, item counts, and status.', + inputSchema, + outputSchema, +}) +export class OrderSummaryTool extends ToolContext { + async execute(input: OrderSummaryInput): Promise { + const repo = this.get(ORDERS_REPO); + const order = await repo.findById(input.orderId); + // `order` may carry { …, internalNotes, paymentProviderRef, debug } + // — none of those are in outputSchema, so they're stripped before returning. + return { + orderId: order.id, + customer: order.customerName, + totalUsd: order.totalCents / 100, + itemCount: order.items.length, + pendingCount: order.items.filter((i) => i.status === 'pending').length, + status: order.status, + }; + } +} +``` + +## What This Demonstrates + +- Declaring `outputSchema` as a Zod raw shape `{ field: z.string(), … }` +- Constraining values with `.int().min(0)` so invalid output is rejected at the boundary +- Letting unrelated fields returned by the implementation (e.g. an upstream API's extras) be stripped silently +- Deriving `OrderSummaryOutput` once so the type and runtime contract can't drift diff --git a/libs/skills/catalog/create-tool/examples/04-tool-with-zod-schema-output.md b/libs/skills/catalog/create-tool/examples/04-tool-with-zod-schema-output.md new file mode 100644 index 000000000..97f4ae36f --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/04-tool-with-zod-schema-output.md @@ -0,0 +1,97 @@ +--- +name: 04-tool-with-zod-schema-output +level: advanced +description: 'Tool returning a discriminated union via a full `z.discriminatedUnion(...)` outputSchema — for outputs that branch on a kind field.' +tags: [output-schema, zod-schema, discriminated-union] +features: + - 'Using a full Zod schema (`z.discriminatedUnion(...)`) as `outputSchema` instead of a raw shape' + - 'Branching the runtime output on a discriminant `kind` literal' + - 'Letting TypeScript narrow the return type per branch (via `as const` on the discriminant)' + - 'Knowing when full Zod schemas are the right pick (unions, transforms) and when a raw shape is enough' +--- + +# Tool With Zod Schema Output + +Tool returning a discriminated union via a full `z.discriminatedUnion(...)` outputSchema — for outputs that branch on a kind field. + +When the output has more than one shape (e.g. `user` vs `group`), use a full Zod schema instead of a raw shape. `z.discriminatedUnion` is the cleanest pattern. + +## Code + +```typescript +// src/apps/main/tools/resolve-principal.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { + handle: z.string().describe('A user or group handle, e.g. `@ada` or `#engineering`'), +}; + +// Full Zod schema — discriminated union on `kind`. +export const outputSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('user'), + id: z.string(), + name: z.string(), + email: z.string().email(), + }), + z.object({ + kind: z.literal('group'), + id: z.string(), + name: z.string(), + memberCount: z.number().int().min(0), + }), +]); + +export type ResolvePrincipalInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type ResolvePrincipalOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/resolve-principal.tool.ts +import { PublicMcpError, Tool, ToolContext } from '@frontmcp/sdk'; + +import { PRINCIPALS } from '../tokens'; +import { + inputSchema, + outputSchema, + type ResolvePrincipalInput, + type ResolvePrincipalOutput, +} from './resolve-principal.schema'; + +@Tool({ + name: 'resolve_principal', + description: 'Resolve a handle to a user or group', + inputSchema, + outputSchema, +}) +export class ResolvePrincipalTool extends ToolContext { + async execute(input: ResolvePrincipalInput): Promise { + const svc = this.get(PRINCIPALS); + if (input.handle.startsWith('@')) { + const user = await svc.findUserByHandle(input.handle.slice(1)); + return { kind: 'user' as const, id: user.id, name: user.name, email: user.email }; + } + if (input.handle.startsWith('#')) { + const group = await svc.findGroupBySlug(input.handle.slice(1)); + return { kind: 'group' as const, id: group.id, name: group.name, memberCount: group.members.length }; + } + this.fail(new PublicMcpError(`Unknown handle prefix: ${input.handle[0]}`)); + } +} +``` + +## What This Demonstrates + +- Using a full Zod schema (`z.discriminatedUnion(...)`) as `outputSchema` instead of a raw shape +- Branching the runtime output on a discriminant `kind` literal +- Letting TypeScript narrow the return type per branch (via `as const` on the discriminant) +- Knowing when full Zod schemas are the right pick (unions, transforms) and when a raw shape is enough + +## When to use a full Zod schema for output + +| Use raw shape | Use full Zod schema | +| ---------------------------------------- | -------------------------------------------------------- | +| Single object with a fixed set of fields | Union of multiple shapes (`z.discriminatedUnion`) | +| All fields known statically | Arrays of complex objects (`z.array(z.object(...))`) | +| No transforms / refinements needed | Need `z.transform(...)`, `z.refine(...)`, `z.brand(...)` | +| Default and recommended | When the raw shape can't express the contract | diff --git a/libs/skills/catalog/create-tool/examples/05-tool-with-primitive-output.md b/libs/skills/catalog/create-tool/examples/05-tool-with-primitive-output.md new file mode 100644 index 000000000..594fec094 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/05-tool-with-primitive-output.md @@ -0,0 +1,93 @@ +--- +name: 05-tool-with-primitive-output +level: basic +description: "Tool returning a single primitive — `outputSchema: 'string' | 'number' | 'boolean' | 'date'` for single-value outputs." +tags: [output-schema, primitive-output] +features: + - "Using a primitive literal (`'string'`, `'number'`, `'boolean'`, `'date'`) for `outputSchema`" + - 'Returning the bare value directly from `execute()` instead of wrapping it' + - Picking primitive literals over a one-field Zod shape for ergonomic clarity + - 'Four concrete tools in one file (`fmt_currency`, `add`, `is_palindrome`, `now`) demonstrating each primitive form' +--- + +# Tool With Primitive Output + +Tool returning a single primitive — `outputSchema: 'string' | 'number' | 'boolean' | 'date'` for single-value outputs. + +For tools that return a single value, declare `outputSchema` as a primitive literal. The framework wraps the bare return in the right MCP content block. + +## Code + +```typescript +// src/apps/main/tools/primitives.tool.ts +import { Tool, tool, ToolContext, z } from '@frontmcp/sdk'; + +// 1. string output +@Tool({ + name: 'fmt_currency', + description: 'Format a number as USD', + inputSchema: { amount: z.number() }, + outputSchema: 'string', +}) +export class FmtCurrencyTool extends ToolContext { + execute(input: { amount: number }): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(input.amount); + } +} + +// 2. number output (function-style) +export const Add = tool({ + name: 'add', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + outputSchema: 'number', +})((input) => input.a + input.b); + +// 3. boolean output +@Tool({ + name: 'is_palindrome', + description: 'Check whether a string reads the same forward and backward', + inputSchema: { text: z.string() }, + outputSchema: 'boolean', +}) +export class IsPalindromeTool extends ToolContext { + execute(input: { text: string }): boolean { + const t = input.text.toLowerCase().replace(/[^a-z0-9]/g, ''); + return t === t.split('').reverse().join(''); + } +} + +// 4. date output +@Tool({ + name: 'now', + description: 'Current server time', + inputSchema: {}, + outputSchema: 'date', +}) +export class NowTool extends ToolContext { + execute(): Date { + return new Date(); + } +} +``` + +## What This Demonstrates + +- Using a primitive literal (`'string'`, `'number'`, `'boolean'`, `'date'`) for `outputSchema` +- Returning the bare value directly from `execute()` instead of wrapping it +- Picking primitive literals over a one-field Zod shape for ergonomic clarity +- Four concrete tools in one file (`fmt_currency`, `add`, `is_palindrome`, `now`) demonstrating each primitive form + +## When to pick primitive literals over a Zod shape + +```typescript +// ✅ primitive literal — clean +outputSchema: 'number', +execute() { return 42; } + +// ❌ one-field Zod shape — unnecessarily nested +outputSchema: { value: z.number() }, +execute() { return { value: 42 }; } +``` + +The primitive form returns the bare value; the shape form wraps it in `{ value }`. Pick primitive when you literally want one value back. diff --git a/libs/skills/catalog/create-tool/examples/06-tool-with-media-output.md b/libs/skills/catalog/create-tool/examples/06-tool-with-media-output.md new file mode 100644 index 000000000..c810d2a78 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/06-tool-with-media-output.md @@ -0,0 +1,104 @@ +--- +name: 06-tool-with-media-output +level: intermediate +description: "Tool returning binary content (image / audio) or a multi-content array of `[text, image]` — for outputs that aren't plain JSON." +tags: [output-schema, media-output, image, multi-content] +features: + - "Returning a base64-encoded image with `outputSchema: 'image'` and `{ data, mimeType }`" + - "Returning audio with `outputSchema: 'audio'` (same `{ data, mimeType }` shape, audio MIME types)" + - "Returning multi-content via `outputSchema: ['string', 'image']` — text summary + annotated image in one response" + - "When to pick a media literal vs `'resource_link'` (host-fetched URI)" +--- + +# Tool With Media Output + +Tool returning binary content (image / audio) or a multi-content array of `[text, image]` — for outputs that aren't plain JSON. + +Media outputs use the literal forms: `'image'`, `'audio'`, `'resource'`, `'resource_link'`, or a mixed array like `['string', 'image']`. + +## Code + +```typescript +// src/apps/main/tools/render-chart.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +// 1. Image output — base64-encoded +@Tool({ + name: 'render_chart', + description: 'Render a bar chart as PNG', + inputSchema: { + labels: z.array(z.string()), + values: z.array(z.number()), + }, + outputSchema: 'image', +}) +export class RenderChartTool extends ToolContext { + async execute(input: { labels: string[]; values: number[] }) { + const pngBuffer = await this.renderPng(input); + return { + data: pngBuffer.toString('base64'), + mimeType: 'image/png' as const, + }; + } + + private async renderPng(_input: { labels: string[]; values: number[] }): Promise { + return Buffer.from('iVBORw0KGgo…', 'base64'); // tiny placeholder + } +} + +// 2. Audio output +@Tool({ + name: 'tts', + description: 'Synthesize speech from text', + inputSchema: { text: z.string() }, + outputSchema: 'audio', +}) +export class TtsTool extends ToolContext { + async execute(input: { text: string }) { + const wavBuffer = await this.synthesize(input.text); + return { + data: wavBuffer.toString('base64'), + mimeType: 'audio/wav' as const, + }; + } + + private async synthesize(_text: string): Promise { + return Buffer.alloc(0); + } +} + +// 3. Multi-content — text summary + annotated image +@Tool({ + name: 'analyze_image', + description: 'Detect objects and return a summary + annotated image', + inputSchema: { imageUrl: z.string().url() }, + outputSchema: ['string', 'image'], +}) +export class AnalyzeImageTool extends ToolContext { + async execute(input: { imageUrl: string }) { + const detection = await this.detect(input.imageUrl); + const summary = `Detected: ${detection.objects.join(', ')}.`; + const annotated = await this.annotate(input.imageUrl, detection); + return [summary, { data: annotated.toString('base64'), mimeType: 'image/png' as const }] as const; + } + + private async detect(_url: string) { + return { objects: ['cat', 'laptop'] }; + } + private async annotate(_url: string, _d: { objects: string[] }): Promise { + return Buffer.alloc(0); + } +} +``` + +## What This Demonstrates + +- Returning a base64-encoded image with `outputSchema: 'image'` and `{ data, mimeType }` +- Returning audio with `outputSchema: 'audio'` (same `{ data, mimeType }` shape, audio MIME types) +- Returning multi-content via `outputSchema: ['string', 'image']` — text summary + annotated image in one response +- When to pick a media literal vs `'resource_link'` (host-fetched URI) + +## Media literal vs `'resource_link'` + +- **`'image'` / `'audio'`** — inlines the bytes (base64) in the response. Best for small payloads (< ~1 MB). Simple — the client gets the data immediately. +- **`'resource_link'`** — returns `{ uri: 'custom://…' }`; the client calls `resources/read` to fetch. Best for large payloads or when caching matters — see [`26-tool-with-resource-link-output`](./26-tool-with-resource-link-output.md). diff --git a/libs/skills/catalog/create-tool/examples/08-tool-with-provider-injection.md b/libs/skills/catalog/create-tool/examples/08-tool-with-provider-injection.md new file mode 100644 index 000000000..006a8a242 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/08-tool-with-provider-injection.md @@ -0,0 +1,110 @@ +--- +name: 08-tool-with-provider-injection +level: intermediate +description: 'Tool that resolves a DI-registered service via `this.get(TOKEN)` and uses it to power `execute()` — the standard pattern for tools that talk to a database or external API.' +tags: [di, provider, this.get, error-handling] +features: + - "Defining a typed DI token with `Symbol('UserService')` and `Token`" + - 'Implementing a `@Provider` and registering it in the same `@App` as the tool' + - 'Resolving the service inside `execute()` via `this.get(USER_SERVICE)` (throws when missing)' + - 'Translating "not found" into `ResourceNotFoundError` via `this.fail(...)` so the client gets a proper MCP error code (-32002)' +--- + +# Tool With Provider Injection + +Tool that resolves a DI-registered service via `this.get(TOKEN)` and uses it to power `execute()` — the standard pattern for tools that talk to a database or external API. + +The canonical pattern. A service lives behind a typed token, gets registered as a `@Provider` in the same `@App`, and the tool resolves it via `this.get(TOKEN)`. + +## Code + +```typescript +// src/apps/main/tokens.ts +import type { Token } from '@frontmcp/di'; + +export interface UserService { + findById(id: string): Promise<{ id: string; name: string; email: string } | null>; +} +export const USER_SERVICE: Token = Symbol('UserService'); +``` + +```typescript +// src/apps/main/providers/user-service.provider.ts +import { Provider } from '@frontmcp/sdk'; + +import { USER_SERVICE, type UserService } from '../tokens'; + +@Provider({ provide: USER_SERVICE }) +export class UserServiceProvider implements UserService { + async findById(id: string) { + // pretend this hits a database + if (id === 'u_1') return { id: 'u_1', name: 'Ada', email: 'ada@example.com' }; + return null; + } +} +``` + +```typescript +// src/apps/main/tools/get-user.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { id: z.string().describe('User ID') }; +export const outputSchema = { id: z.string(), name: z.string(), email: z.string().email() }; + +export type GetUserInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GetUserOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/get-user.tool.ts +import { ResourceNotFoundError, Tool, ToolContext } from '@frontmcp/sdk'; + +import { USER_SERVICE } from '../tokens'; +import { inputSchema, outputSchema, type GetUserInput, type GetUserOutput } from './get-user.schema'; + +@Tool({ + name: 'get_user', + description: 'Get a user by ID', + inputSchema, + outputSchema, +}) +export class GetUserTool extends ToolContext { + async execute(input: GetUserInput): Promise { + const users = this.get(USER_SERVICE); // throws DependencyNotFoundError if not registered + const user = await users.findById(input.id); + if (!user) { + this.fail(new ResourceNotFoundError(`user:${input.id}`)); // never returns + } + return user; // typed as non-null because this.fail is `never` + } +} +``` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { UserServiceProvider } from './providers/user-service.provider'; +import { GetUserTool } from './tools/get-user.tool'; + +@App({ + name: 'main', + providers: [UserServiceProvider], + tools: [GetUserTool], +}) +export class MainApp {} +``` + +> **Testing.** Tests for tools with DI use `@frontmcp/testing`'s `TestServer` with the provider replaced for the test scope. The full pattern (including the canonical `test({ mcp })` fixture and `mcpMatchers`) lives in the dedicated `testing` skill. + +## What This Demonstrates + +- Defining a typed DI token with `Symbol('UserService')` and `Token` +- Implementing a `@Provider` and registering it in the same `@App` as the tool +- Resolving the service inside `execute()` via `this.get(USER_SERVICE)` (throws when missing) +- Translating "not found" into `ResourceNotFoundError` via `this.fail(...)` so the client gets a proper MCP error code (-32002) + +## `this.get` vs `this.tryGet` + +- `this.get(TOKEN)` — throws `DependencyNotFoundError` if not registered. Use when the tool genuinely requires the dep. +- `this.tryGet(TOKEN)` — returns `undefined` if not registered. Use when the tool degrades gracefully (e.g. optional cache). diff --git a/libs/skills/catalog/create-tool/examples/09-tool-with-multiple-providers.md b/libs/skills/catalog/create-tool/examples/09-tool-with-multiple-providers.md new file mode 100644 index 000000000..1cccb7e1d --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/09-tool-with-multiple-providers.md @@ -0,0 +1,107 @@ +--- +name: 09-tool-with-multiple-providers +level: intermediate +description: 'Tool composing three DI services — config (env-only), cache (optional, `tryGet`), and database (required) — the realistic shape for a production tool.' +tags: [di, multiple-providers, cache-aside, tryGet] +features: + - 'Resolving multiple providers via `this.get(TOKEN)` and `this.tryGet(TOKEN)`' + - 'Cache-aside pattern — check `tryGet(CACHE)` first, fall back to the database' + - 'Reading typed config from a `CONFIG` token vs `process.env` directly' + - Letting the tool work in production (with cache) AND in test (without it) +--- + +# Tool With Multiple Providers + +Tool composing three DI services — config (env-only), cache (optional, `tryGet`), and database (required) — the realistic shape for a production tool. + +Production tools usually compose several services: config + cache + database is the standard trio. This example wires all three. + +## Code + +```typescript +// src/apps/main/tokens.ts +import type { Token } from '@frontmcp/di'; + +export interface AppConfig { + weatherApiKey: string; + cacheTtlSeconds: number; +} +export interface CacheService { + get(key: string): Promise; + set(key: string, value: T, ttlSeconds: number): Promise; +} +export interface WeatherRepo { + loadFromDb(city: string): Promise<{ temperatureF: number; conditions: string } | null>; +} + +export const CONFIG: Token = Symbol('AppConfig'); +export const CACHE: Token = Symbol('CacheService'); +export const WEATHER_REPO: Token = Symbol('WeatherRepo'); +``` + +```typescript +// src/apps/main/tools/get-weather.tool.ts +import { ResourceNotFoundError, Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { CACHE, CONFIG, WEATHER_REPO } from '../tokens'; + +const inputSchema = { city: z.string().describe('City name') }; +const outputSchema = { + city: z.string(), + temperatureF: z.number(), + conditions: z.string(), + cached: z.boolean(), +}; + +@Tool({ + name: 'get_weather', + description: 'Current weather — cache-aside, falls back to the DB', + inputSchema, + outputSchema, +}) +export class GetWeatherTool extends ToolContext { + async execute(input: { city: string }) { + const config = this.get(CONFIG); // required — throws if missing + const cache = this.tryGet(CACHE); // optional — production has it, tests skip it + const repo = this.get(WEATHER_REPO); // required + + const cacheKey = `weather:${input.city.toLowerCase()}`; + + if (cache) { + const cached = await cache.get<{ temperatureF: number; conditions: string }>(cacheKey); + if (cached) { + return { city: input.city, ...cached, cached: true }; + } + } + + const fresh = await repo.loadFromDb(input.city); + if (!fresh) { + this.fail(new ResourceNotFoundError(`weather:${input.city}`)); + } + + if (cache) { + await cache.set(cacheKey, fresh, config.cacheTtlSeconds); + } + + return { city: input.city, ...fresh, cached: false }; + } +} +``` + +## What This Demonstrates + +- Resolving multiple providers via `this.get(TOKEN)` and `this.tryGet(TOKEN)` +- Cache-aside pattern — check `tryGet(CACHE)` first, fall back to the database +- Reading typed config from a `CONFIG` token vs `process.env` directly +- Letting the tool work in production (with cache) AND in test (without it) + +## Why `tryGet` for cache + +- In production: the app registers a Redis-backed cache provider. `this.tryGet(CACHE)` returns it. +- In tests: tests skip registering the cache. `tryGet(CACHE)` returns `undefined`. The tool falls through to the DB. No special test setup required. + +## Why `this.get(CONFIG)` instead of `process.env.WEATHER_API_KEY` + +- Config goes through a typed provider — tests inject a mock `{ weatherApiKey: 'test', cacheTtlSeconds: 0 }` without touching `process.env`. +- The shape is enforced by TypeScript; renaming a config field is a compile-time error across all callers. +- Multiple apps on the same server can have different config provider implementations. diff --git a/libs/skills/catalog/create-tool/examples/11-tool-with-fetch.md b/libs/skills/catalog/create-tool/examples/11-tool-with-fetch.md new file mode 100644 index 000000000..5694a4904 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/11-tool-with-fetch.md @@ -0,0 +1,93 @@ +--- +name: 11-tool-with-fetch +level: intermediate +description: 'Tool calling an external HTTP API with `this.fetch` — context propagation, status-code handling, and timing out via the abort signal.' +tags: [fetch, http, external-api, error-handling] +features: + - 'Using `this.fetch(url, init?)` so trace context propagates to the upstream service' + - 'Translating non-2xx HTTP responses into `PublicMcpError` so the MCP client gets a clean error' + - 'Passing `this.context.abortSignal` to the fetch so a tool `timeout` cancels in-flight HTTP work' + - "Letting genuine network errors (DNS failure, ECONNREFUSED) propagate to the framework's error flow" +--- + +# Tool With Fetch + +Tool calling an external HTTP API with `this.fetch` — context propagation, status-code handling, and timing out via the abort signal. + +`this.fetch` is the standard `fetch` plus trace-context propagation. Always use it instead of bare `fetch` so distributed tracing stitches the upstream call into the same trace as the MCP request. + +## Code + +```typescript +// src/apps/main/tools/get-weather.tool.ts +import { PublicMcpError, ResourceNotFoundError, Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + city: z.string().describe('City name, e.g. "Seattle"'), + units: z.enum(['celsius', 'fahrenheit']).default('fahrenheit'), +}; +const outputSchema = { + city: z.string(), + temperature: z.number(), + conditions: z.string(), + units: z.enum(['celsius', 'fahrenheit']), +}; + +@Tool({ + name: 'get_weather', + description: 'Current weather from api.weather.example', + inputSchema, + outputSchema, + timeout: { executeMs: 10_000 }, // abort the fetch if the upstream hangs +}) +export class GetWeatherTool extends ToolContext { + async execute(input: { city: string; units: 'celsius' | 'fahrenheit' }) { + const url = new URL('https://api.weather.example/v1/current'); + url.searchParams.set('city', input.city); + url.searchParams.set('units', input.units); + + const response = await this.fetch(url, { + headers: { accept: 'application/json' }, + signal: this.context.abortSignal, // propagate the tool timeout + }); + + if (response.status === 404) { + this.fail(new ResourceNotFoundError(`weather:${input.city}`)); + } + if (!response.ok) { + this.fail(new PublicMcpError(`Weather API returned ${response.status} ${response.statusText}`)); + } + + const body = (await response.json()) as { temp: number; summary: string }; + return { + city: input.city, + temperature: body.temp, + conditions: body.summary, + units: input.units, + }; + } +} +``` + +## What This Demonstrates + +- Using `this.fetch(url, init?)` so trace context propagates to the upstream service +- Translating non-2xx HTTP responses into `PublicMcpError` so the MCP client gets a clean error +- Passing `this.context.abortSignal` to the fetch so a tool `timeout` cancels in-flight HTTP work +- Letting genuine network errors (DNS failure, ECONNREFUSED) propagate to the framework's error flow + +## Don't do this + +```typescript +// ❌ swallows network errors, hides infrastructure problems from observability +async execute(input) { + try { + const response = await this.fetch(url); + return await response.json(); + } catch (err) { + return { error: String(err) }; + } +} +``` + +Let infrastructure errors propagate. The framework wraps them in `InternalError` (`-32603`) for the client and logs them properly for ops. Only convert specific business-level conditions (status codes you know about) to `this.fail`. diff --git a/libs/skills/catalog/create-tool/examples/12-tool-with-fetch-and-retries.md b/libs/skills/catalog/create-tool/examples/12-tool-with-fetch-and-retries.md new file mode 100644 index 000000000..8b964835c --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/12-tool-with-fetch-and-retries.md @@ -0,0 +1,114 @@ +--- +name: 12-tool-with-fetch-and-retries +level: advanced +description: 'Tool calling a flaky external API with exponential backoff retries, an Idempotency-Key for safety, and respect for `Retry-After` on 429s.' +tags: [fetch, retries, exponential-backoff, idempotency-key, 429-rate-limit] +features: + - Retrying on transient errors (5xx + 429) with exponential backoff plus jitter + - "Generating an `Idempotency-Key` so retried POSTs don't duplicate side effects on the upstream" + - 'Respecting an upstream `Retry-After` header on 429 responses instead of guessing' + - "Capping the total retry budget with `timeout: { executeMs }` so a wedged upstream can't hang the call indefinitely" +--- + +# Tool With Fetch And Retries + +Tool calling a flaky external API with exponential backoff retries, an Idempotency-Key for safety, and respect for `Retry-After` on 429s. + +Real upstreams flake. This shows the pattern that survives them: retry the right errors, back off correctly, generate an idempotency key so retried POSTs don't double-fire, and cap the whole thing with `timeout`. + +## Code + +```typescript +// src/apps/main/tools/create-issue.tool.ts +import { PublicMcpError, Tool, ToolContext, z } from '@frontmcp/sdk'; +import { randomUUID } from '@frontmcp/utils'; + +const inputSchema = { + repo: z.string().regex(/^[^\/]+\/[^\/]+$/, 'expected owner/repo'), + title: z.string().min(1).max(256), + body: z.string().max(65_536).optional(), +}; +const outputSchema = { issueNumber: z.number().int().min(1), url: z.string().url() }; + +const MAX_ATTEMPTS = 4; +const BASE_DELAY_MS = 200; + +@Tool({ + name: 'create_issue', + description: 'Create a GitHub issue (retries on 5xx / 429)', + inputSchema, + outputSchema, + rateLimit: { maxRequests: 30, windowMs: 60_000 }, + timeout: { executeMs: 30_000 }, // hard cap across all retries + annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }, + authProviders: ['github'], +}) +export class CreateIssueTool extends ToolContext { + async execute(input: { repo: string; title: string; body?: string }) { + const headers = await this.authProviders.headers('github'); + const idempotencyKey = randomUUID(); + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const response = await this.fetch(`https://api.github.com/repos/${input.repo}/issues`, { + method: 'POST', + headers: { + ...headers, + 'content-type': 'application/json', + 'idempotency-key': idempotencyKey, + }, + body: JSON.stringify({ title: input.title, body: input.body }), + signal: this.context.abortSignal, + }); + + if (response.ok) { + const data = (await response.json()) as { number: number; html_url: string }; + return { issueNumber: data.number, url: data.html_url }; + } + + // Non-retryable client errors → fail immediately + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + const detail = await response.text(); + this.fail(new PublicMcpError(`GitHub returned ${response.status}: ${detail.slice(0, 200)}`)); + } + + // Retryable (5xx or 429) — back off and try again + if (attempt === MAX_ATTEMPTS) { + this.fail(new PublicMcpError(`GitHub upstream failed after ${MAX_ATTEMPTS} attempts`)); + } + + // Retry-After is either a number of seconds ("120") or an HTTP-date — handle both. + const retryAfter = response.headers.get('retry-after'); + const retryAfterMs = (() => { + if (!retryAfter) return undefined; + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000); + const at = Date.parse(retryAfter); + return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined; + })(); + const baseDelay = retryAfterMs ?? BASE_DELAY_MS * 2 ** (attempt - 1); + const jitter = Math.floor(Math.random() * baseDelay * 0.2); + await this.notify( + `Attempt ${attempt} returned ${response.status}; retrying in ${baseDelay + jitter}ms`, + 'warning', + ); + await new Promise((r) => setTimeout(r, baseDelay + jitter)); + } + /* unreachable — this.fail above never returns */ + this.fail(new PublicMcpError('unreachable')); + } +} +``` + +## What This Demonstrates + +- Retrying on transient errors (5xx + 429) with exponential backoff plus jitter +- Generating an `Idempotency-Key` so retried POSTs don't duplicate side effects on the upstream +- Respecting an upstream `Retry-After` header on 429 responses instead of guessing +- Capping the total retry budget with `timeout: { executeMs }` so a wedged upstream can't hang the call indefinitely + +## Why these choices + +- **`Idempotency-Key` is critical for POSTs.** Without it, a retry after a network glitch can create two issues. GitHub honors `Idempotency-Key` server-side; many other APIs (Stripe, etc.) do too. +- **Jitter prevents thundering herds.** All clients retrying at exactly 200ms / 400ms / 800ms create synchronized spikes. ±20% jitter spreads them. +- **`timeout: { executeMs: 30_000 }` is the safety net.** The retry loop alone can take 200 + 400 + 800 = 1.4s just in backoff. With a slow upstream, total time spirals — the tool-level timeout caps it. +- **Don't retry 4xx (except 429).** 4xx means "your request is wrong" — retrying won't help and may double up the upstream's accounting. diff --git a/libs/skills/catalog/create-tool/examples/13-tool-with-single-auth-provider.md b/libs/skills/catalog/create-tool/examples/13-tool-with-single-auth-provider.md new file mode 100644 index 000000000..ae3317565 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/13-tool-with-single-auth-provider.md @@ -0,0 +1,84 @@ +--- +name: 13-tool-with-single-auth-provider +level: intermediate +description: "Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand — credentials loaded before `execute()` runs." +tags: [auth-providers, oauth, github, this.authProviders] +features: + - "Declaring a single required OAuth provider with the `authProviders: ['github']` shorthand" + - "Reading pre-formatted credentials via `await this.authProviders.headers('github')`" + - 'Letting the framework reject unauthenticated calls before `execute()` runs (no auth-check boilerplate)' + - 'Trusting the framework to handle token refresh, expiration, and the OAuth start URL' +--- + +# Tool With Single Auth Provider + +Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand — credentials loaded before `execute()` runs. + +The shorthand form. By the time `execute()` runs, the user has completed the OAuth flow and credentials are in the vault — `this.authProviders.headers('github')` returns `{ Authorization: 'Bearer …' }` ready to forward. + +## Code + +```typescript +// src/apps/main/tools/list-repos.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + visibility: z.enum(['all', 'public', 'private']).default('all'), + perPage: z.number().int().min(1).max(100).default(30), +}; +const outputSchema = { + repos: z.array(z.object({ fullName: z.string(), stars: z.number().int(), private: z.boolean() })), +}; + +@Tool({ + name: 'list_repos', + description: 'List GitHub repos the authenticated user has access to', + inputSchema, + outputSchema, + authProviders: ['github'], // shorthand — single, required provider + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, +}) +export class ListReposTool extends ToolContext { + async execute(input: { visibility: 'all' | 'public' | 'private'; perPage: number }) { + const headers = await this.authProviders.headers('github'); + + const url = new URL('https://api.github.com/user/repos'); + url.searchParams.set('visibility', input.visibility); + url.searchParams.set('per_page', String(input.perPage)); + + const response = await this.fetch(url, { headers }); + const data = (await response.json()) as Array<{ full_name: string; stargazers_count: number; private: boolean }>; + + return { + repos: data.map((r) => ({ + fullName: r.full_name, + stars: r.stargazers_count, + private: r.private, + })), + }; + } +} +``` + +## What This Demonstrates + +- Declaring a single required OAuth provider with the `authProviders: ['github']` shorthand +- Reading pre-formatted credentials via `await this.authProviders.headers('github')` +- Letting the framework reject unauthenticated calls before `execute()` runs (no auth-check boilerplate) +- Trusting the framework to handle token refresh, expiration, and the OAuth start URL + +## What you don't have to write + +```typescript +// ❌ unnecessary — the framework already did all of this before execute() ran: +if (!this.context.authInfo.tokens?.github) { + this.fail(new PublicMcpError('No GitHub auth — please sign in', { authUrl: '…' })); +} +const accessToken = this.context.authInfo.tokens.github; +if (Date.now() >= accessToken.expiresAt) { + /* refresh */ +} +const headers = { Authorization: `Bearer ${accessToken.value}` }; +``` + +The single line `await this.authProviders.headers('github')` covers it all. diff --git a/libs/skills/catalog/create-tool/examples/14-tool-with-multiple-auth-providers.md b/libs/skills/catalog/create-tool/examples/14-tool-with-multiple-auth-providers.md new file mode 100644 index 000000000..3358cc601 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/14-tool-with-multiple-auth-providers.md @@ -0,0 +1,102 @@ +--- +name: 14-tool-with-multiple-auth-providers +level: advanced +description: 'Tool with the full `authProviders` mapping form — one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing.' +tags: [auth-providers, oauth, scopes, optional-auth, this.authProviders.tryHeaders] +features: + - 'Using the object form of `authProviders` to set `required`, `scopes`, and `alias`' + - Requesting specific OAuth scopes so the framework triggers incremental auth when missing + - "Resolving an optional provider via `await this.authProviders.tryHeaders('cloud')` (returns `null` when absent)" + - "Branching the tool's behavior — full deploy when both providers are present; preview-only when the cloud provider is missing" +--- + +# Tool With Multiple Auth Providers + +Tool with the full `authProviders` mapping form — one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing. + +The full form unlocks scopes, optional providers, and aliases. Use it when the simple shorthand isn't enough. + +## Code + +```typescript +// src/apps/main/tools/deploy-app.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + repo: z.string().regex(/^[^/]+\/[^/]+$/), + environment: z.enum(['staging', 'production']), + dryRun: z.boolean().default(false), +}; +const outputSchema = { + deploymentId: z.string(), + url: z.string().url(), + mode: z.enum(['preview', 'deployed']), +}; + +@Tool({ + name: 'deploy_app', + description: 'Build and deploy a repo to cloud', + inputSchema, + outputSchema, + authProviders: [ + { name: 'github', required: true, scopes: ['repo', 'workflow'] }, // required + scoped + { name: 'aws', required: false, alias: 'cloud' }, // optional, aliased + ], + annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true }, +}) +export class DeployAppTool extends ToolContext { + async execute(input: { repo: string; environment: 'staging' | 'production'; dryRun: boolean }) { + const githubHeaders = await this.authProviders.headers('github'); + const cloudHeaders = await this.authProviders.tryHeaders('cloud'); // null when AWS not connected + + // 1. Build artifact from the repo (always works — we have GitHub creds) + const buildId = await this.triggerBuild(input.repo, githubHeaders); + + // 2. Deploy — only if cloud creds are present + if (!cloudHeaders || input.dryRun) { + return { + deploymentId: `preview-${buildId}`, + url: `https://preview.example.com/${buildId}`, + mode: 'preview' as const, + }; + } + + const deploymentId = await this.deployToCloud(buildId, input.environment, cloudHeaders); + return { + deploymentId, + url: `https://${input.environment}.example.com/${deploymentId}`, + mode: 'deployed' as const, + }; + } + + private async triggerBuild(_repo: string, _headers: Headers): Promise { + return 'b_42'; + } + private async deployToCloud(_buildId: string, _env: string, _headers: Headers): Promise { + return 'd_99'; + } +} +``` + +## What This Demonstrates + +- Using the object form of `authProviders` to set `required`, `scopes`, and `alias` +- Requesting specific OAuth scopes so the framework triggers incremental auth when missing +- Resolving an optional provider via `await this.authProviders.tryHeaders('cloud')` (returns `null` when absent) +- Branching the tool's behavior — full deploy when both providers are present; preview-only when the cloud provider is missing + +## Field reference (from auth-providers.md) + +| Field | Default | Meaning | +| ---------- | -------- | ------------------------------------------------------------------------------------- | +| `name` | — | Provider name — must match a registered `@AuthProvider` | +| `required` | `true` | If `true`, the tool errors before `execute()` runs when creds are missing | +| `scopes` | — | OAuth scopes — triggers incremental auth if the session lacks them | +| `alias` | = `name` | Local name for the provider — useful when two tools use the same provider differently | + +## When to use the object form + +- Need scopes (`required: true, scopes: ['repo']`) — must use the object form +- Need optional providers (`required: false`) — must use the object form +- Need to alias the provider name (rare) — must use the object form +- Just one always-required provider with default scopes → the `authProviders: ['github']` shorthand is shorter diff --git a/libs/skills/catalog/create-tool/examples/15-tool-with-credential-vault.md b/libs/skills/catalog/create-tool/examples/15-tool-with-credential-vault.md new file mode 100644 index 000000000..625147d7a --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/15-tool-with-credential-vault.md @@ -0,0 +1,113 @@ +--- +name: 15-tool-with-credential-vault +level: advanced +description: "Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault — the pattern for credentials that aren't OAuth." +tags: [auth-providers, credential-vault, slack-webhook, encryption-at-rest] +features: + - "Declaring a vault-backed auth provider with `authProviders: ['slack-webhook']`" + - "Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` — same API as OAuth" + - Letting the framework handle per-session AES-256-GCM encryption at rest (Redis or memory store) + - Knowing when to pick the vault (static secrets the user knows) vs OAuth (delegated identity) +--- + +# Tool With Credential Vault + +Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault — the pattern for credentials that aren't OAuth. + +For credentials that aren't OAuth — webhook URLs, API keys the user pastes in, custom tokens — use a vault-backed auth provider. Same API as OAuth from the tool's perspective; the framework handles per-session encryption and key derivation. + +## Code + +```typescript +// src/apps/main/tools/send-to-slack.tool.ts +import { PublicMcpError, Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + channel: z.string().regex(/^#/).describe('Slack channel, e.g. #ops'), + text: z.string().min(1).max(4_000), + username: z.string().optional(), + iconEmoji: z.string().optional(), +}; +const outputSchema = { sent: z.boolean(), channel: z.string() }; + +@Tool({ + name: 'send_to_slack', + description: 'Send a message to a Slack channel via the user-supplied incoming-webhook URL', + inputSchema, + outputSchema, + authProviders: ['slack-webhook'], // vault-backed provider — see auth skill + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true }, +}) +export class SendToSlackTool extends ToolContext { + async execute(input: { channel: string; text: string; username?: string; iconEmoji?: string }) { + const headers = await this.authProviders.headers('slack-webhook'); + // headers includes: + // { 'x-slack-webhook-url': 'https://hooks.slack.com/services/T.../B.../...' } + // The framework reads it from the vault, decrypts with the per-session AES-256-GCM key, + // and never returns the raw value — it's only available indirectly via these headers. + + // Non-null assertion is safe here because we declared `authProviders: ['slack-webhook']` + // (required: true by default), so the framework already rejected the call before `execute()` + // if the vault entry was missing — by the time we read the header, the provider is guaranteed + // to have produced it. If you ever switch the provider to `required: false`, drop the `!` + // and use `?? this.fail(new PublicMcpError('No Slack webhook configured'))` instead. + const webhookUrl = headers.get('x-slack-webhook-url')!; + const response = await this.fetch(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: input.channel, + text: input.text, + username: input.username, + icon_emoji: input.iconEmoji, + }), + }); + + if (!response.ok) { + this.fail(new PublicMcpError(`Slack webhook returned ${response.status}`)); + } + + return { sent: true, channel: input.channel }; + } +} +``` + +## What This Demonstrates + +- Declaring a vault-backed auth provider with `authProviders: ['slack-webhook']` +- Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` — same API as OAuth +- Letting the framework handle per-session AES-256-GCM encryption at rest (Redis or memory store) +- Knowing when to pick the vault (static secrets the user knows) vs OAuth (delegated identity) + +## Vault vs OAuth — when to pick which + +| Vault-backed | OAuth | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| User pastes in a webhook URL, API key, or token they own | User clicks "Connect to GitHub" — provider issues a token to your server | +| Static — doesn't refresh, doesn't expire | Refresh token + access token + expiration | +| Per-session — gone when the session ends (vs. Redis store, then it survives) | Long-lived — usually persists across sessions | +| For "bring your own" integrations (Slack webhooks, custom API keys) | For "log in with" integrations (GitHub, Google, Microsoft) | + +## How vault-backed providers are configured + +Server side, in the `auth` skill: + +```typescript +@FrontMcp({ + auth: { + providers: [ + { + name: 'slack-webhook', + kind: 'vault', + fields: [ + { name: 'webhook-url', type: 'url', label: 'Slack incoming-webhook URL', required: true }, + ], + }, + ], + }, +}) +``` + +The framework renders the credential UI based on `fields`, encrypts the user's input, stores it per-session, and exposes it back to tools via `this.authProviders.headers('slack-webhook')`. + +See the `auth` skill for full vault configuration, encryption-key management, Redis-backed storage, and the credential UI. diff --git a/libs/skills/catalog/create-tool/examples/16-tool-with-rate-limit.md b/libs/skills/catalog/create-tool/examples/16-tool-with-rate-limit.md new file mode 100644 index 000000000..be8e71129 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/16-tool-with-rate-limit.md @@ -0,0 +1,71 @@ +--- +name: 16-tool-with-rate-limit +level: intermediate +description: 'Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute — the protection for expensive / external-API-billed operations.' +tags: [throttling, rate-limit, abuse-protection] +features: + - Capping the tool to N invocations per windowMs (per-session by default) + - "Letting the framework reject over-limit calls with `RateLimitError` (code `'RATE_LIMIT_EXCEEDED'`, HTTP status 429) and a retry-after hint clients can back off against" + - 'Combining `rateLimit` with `annotations.openWorldHint: true` so clients know the tool talks to billed external services' + - Sizing the limit against upstream quota / billing — not just "what feels reasonable" +--- + +# Tool With Rate Limit + +Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute — the protection for expensive / external-API-billed operations. + +The first throttle to reach for. Caps invocations over time. Per-session by default — a runaway agent loop on one session can't burn through a quota that other sessions need. + +## Code + +```typescript +// src/apps/main/tools/translate.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + text: z.string().min(1).max(2_000), + targetLang: z.string().regex(/^[a-z]{2}$/, 'ISO 639-1 code'), +}; +const outputSchema = { translated: z.string(), sourceLang: z.string() }; + +@Tool({ + name: 'translate', + description: 'Translate text via the external translation API (billed per call)', + inputSchema, + outputSchema, + rateLimit: { maxRequests: 60, windowMs: 60_000 }, // 60 calls / minute / session + authProviders: ['translate-api'], + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, +}) +export class TranslateTool extends ToolContext { + async execute(input: { text: string; targetLang: string }) { + const headers = await this.authProviders.headers('translate-api'); + const response = await this.fetch('https://api.translate.example/v1/translate', { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ text: input.text, target: input.targetLang }), + }); + const data = (await response.json()) as { translated: string; detectedSource: string }; + return { translated: data.translated, sourceLang: data.detectedSource }; + } +} +``` + +> **Testing.** The framework throws `RateLimitError` (HTTP 429, code `'RATE_LIMIT_EXCEEDED'`) for over-limit calls. Tests that assert the rate-limit fires after N calls live in the dedicated `testing` skill — the canonical pattern uses `@frontmcp/testing`'s `TestServer` + Playwright `test`/`expect` fixtures. + +## What This Demonstrates + +- Capping the tool to N invocations per windowMs (per-session by default) +- Letting the framework reject over-limit calls with `RateLimitError` (code `'RATE_LIMIT_EXCEEDED'`, HTTP status 429) and a retry-after hint clients can back off against +- Combining `rateLimit` with `annotations.openWorldHint: true` so clients know the tool talks to billed external services +- Sizing the limit against upstream quota / billing — not just "what feels reasonable" + +## Sizing the limit + +- **External API quotas** — set well below the upstream quota. If GitHub allows 5,000/hour authenticated, a per-session limit of 30/min (1,800/hr) gives room for multiple sessions to share the budget. +- **Billed services** — lower. A `translate` call may cost $0.02. 60/min × 60min × 24h × 1 session = $1,728/day worst case. +- **Pure rate concern, not cost** — 100/min is generous for almost anything. + +## Global vs per-session + +The default scope is per-session. For shared resources where the limit must hold across all sessions, see the `throttling` reference for `scope: 'global'` (server-wide). diff --git a/libs/skills/catalog/create-tool/examples/17-tool-with-concurrency-and-timeout.md b/libs/skills/catalog/create-tool/examples/17-tool-with-concurrency-and-timeout.md new file mode 100644 index 000000000..fff649c24 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/17-tool-with-concurrency-and-timeout.md @@ -0,0 +1,96 @@ +--- +name: 17-tool-with-concurrency-and-timeout +level: advanced +description: 'Tool with `concurrency` + `timeout` for a real bottleneck (PDF rendering) — caps simultaneous in-flight work AND hard-caps per-call duration.' +tags: [throttling, concurrency, timeout, abort-signal] +features: + - 'Capping simultaneous in-flight executions with `concurrency: { maxConcurrent }` (server-wide by default)' + - "Hard-bounding any single call with `timeout: { executeMs }` so a wedged invocation can't hold a concurrency slot indefinitely" + - 'Propagating `this.context.abortSignal` to in-flight work so the timeout actually cancels it' + - 'Combining `rateLimit` + `concurrency` + `timeout` as a production triple' +--- + +# Tool With Concurrency And Timeout + +Tool with `concurrency` + `timeout` for a real bottleneck (PDF rendering) — caps simultaneous in-flight work AND hard-caps per-call duration. + +For tools that hold a real bottleneck (CPU / GPU / DB write connection), `concurrency` caps simultaneous in-flight executions. Pair with `timeout` so a stuck call can't permanently block a slot. + +## Code + +```typescript +// src/apps/main/tools/render-pdf.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + template: z.enum(['invoice', 'report', 'contract']), + data: z.record(z.string(), z.unknown()), +}; +const outputSchema = { data: z.string(), mimeType: z.literal('application/pdf'), byteCount: z.number().int() }; + +@Tool({ + name: 'render_pdf', + description: 'Render a PDF from a template + data — capped at 5 concurrent renders', + inputSchema, + outputSchema, + rateLimit: { maxRequests: 100, windowMs: 60_000 }, // outer cap — burst protection + concurrency: { maxConcurrent: 5 }, // inner cap — at most 5 PDFs at once + timeout: { executeMs: 30_000 }, // hard per-call deadline + annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false }, +}) +export class RenderPdfTool extends ToolContext { + async execute(input: { template: 'invoice' | 'report' | 'contract'; data: Record }) { + const pdfBuffer = await this.renderPdf(input.template, input.data, { + signal: this.context.abortSignal, // propagate the tool timeout into the renderer + }); + + return { + data: pdfBuffer.toString('base64'), + mimeType: 'application/pdf' as const, + byteCount: pdfBuffer.byteLength, + }; + } + + private async renderPdf( + _template: string, + _data: Record, + options: { signal: AbortSignal }, + ): Promise { + // pretend this is puppeteer / wkhtmltopdf / a Rust binary — anything that honors AbortSignal + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(Buffer.from('%PDF-1.4 …')), 1_000); + options.signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('aborted')); + }); + }); + } +} +``` + +## What This Demonstrates + +- Capping simultaneous in-flight executions with `concurrency: { maxConcurrent }` (server-wide by default) +- Hard-bounding any single call with `timeout: { executeMs }` so a wedged invocation can't hold a concurrency slot indefinitely +- Propagating `this.context.abortSignal` to in-flight work so the timeout actually cancels it +- Combining `rateLimit` + `concurrency` + `timeout` as a production triple + +## How they interact + +Per call, in order: + +1. `rateLimit` → reject early if over limit (no concurrency slot consumed) +2. `concurrency` → queue until a slot opens (queue depth is unbounded by default) +3. `timeout` → wraps `execute()` once it actually runs; on expiry, throws `ToolTimeoutError` AND fires `this.context.abortSignal` + +The three controls are **orthogonal** — each addresses a different failure mode: + +| Control | Protects against | +| ------------- | ---------------------------------------------- | +| `rateLimit` | Quota / billing overrun | +| `concurrency` | Resource exhaustion (CPU, GPU, DB connections) | +| `timeout` | Stuck calls holding slots forever | + +## Why the abort signal matters + +Without propagating `this.context.abortSignal` to the child work, `timeout` fires but the underlying PDF renderer keeps going — burning CPU even though the call already errored out to the client. With the signal propagated, the renderer can clean up and free the concurrency slot for the next queued call. diff --git a/libs/skills/catalog/create-tool/examples/18-tool-with-progress-and-notify.md b/libs/skills/catalog/create-tool/examples/18-tool-with-progress-and-notify.md new file mode 100644 index 000000000..b8fc7f8cc --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/18-tool-with-progress-and-notify.md @@ -0,0 +1,96 @@ +--- +name: 18-tool-with-progress-and-notify +level: intermediate +description: "Long-running tool emitting progress updates (`this.progress`), log notifications (`this.notify`), and stage markers (`this.mark`) — the standard pattern for jobs you don't want to feel hung." +tags: [progress, notifications, mark, long-running] +features: + - 'Emitting per-item progress with `await this.progress(current, total, message)`' + - 'Sending free-form log notifications at `info` / `warning` / `error` levels with `await this.notify(message, level)`' + - 'Marking execution stages with `this.mark(stage)` so observability tools have breadcrumbs' + - "Letting `this.progress(...)` return `false` cheaply when no progress token was provided (zero-cost when nobody's listening)" +--- + +# Tool With Progress And Notify + +Long-running tool emitting progress updates (`this.progress`), log notifications (`this.notify`), and stage markers (`this.mark`) — the standard pattern for jobs you don't want to feel hung. + +Anything that takes more than a couple of seconds should emit progress. The framework's notifications API is a single line per emission; clients render progress bars / activity logs / breadcrumb timelines from it. + +## Code + +```typescript +// src/apps/main/tools/process-items.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { + items: z.array(z.string()).min(1).max(1_000), +}; +const outputSchema = { + processed: z.number().int(), + failed: z.number().int(), + results: z.array(z.object({ item: z.string(), status: z.enum(['ok', 'failed']) })), +}; + +@Tool({ + name: 'process_items', + description: 'Process a batch of items, emitting progress per item', + inputSchema, + outputSchema, + timeout: { executeMs: 5 * 60_000 }, // 5min ceiling + annotations: { idempotentHint: true, openWorldHint: false }, +}) +export class ProcessItemsTool extends ToolContext { + async execute(input: { items: string[] }) { + this.mark('validation'); + // (Zod already validated; this.mark adds a server-side breadcrumb) + + this.mark('processing'); + const results: { item: string; status: 'ok' | 'failed' }[] = []; + + for (let i = 0; i < input.items.length; i++) { + const item = input.items[i]; + const ok = await this.processOne(item); + results.push({ item, status: ok ? 'ok' : 'failed' }); + + // Emit progress; cheap no-op if the request didn't send a progress token + await this.progress(i + 1, input.items.length, `Processed ${item}`); + } + + const failed = results.filter((r) => r.status === 'failed').length; + if (failed > 0) { + await this.notify(`${failed}/${input.items.length} items failed`, 'warning'); + } else { + await this.notify(`All ${input.items.length} items processed`, 'info'); + } + + this.mark('complete'); + return { processed: results.length, failed, results }; + } + + private async processOne(_item: string): Promise { + return Math.random() > 0.05; + } +} +``` + +> **Testing.** Tests that intercept `this.progress` / `this.notify` events live in the dedicated `testing` skill — `@frontmcp/testing` exposes the notification stream via the `TestServer` + Playwright `test`/`expect` fixture surface. + +## What This Demonstrates + +- Emitting per-item progress with `await this.progress(current, total, message)` +- Sending free-form log notifications at `info` / `warning` / `error` levels with `await this.notify(message, level)` +- Marking execution stages with `this.mark(stage)` so observability tools have breadcrumbs +- Letting `this.progress(...)` return `false` cheaply when no progress token was provided (zero-cost when nobody's listening) + +## When to use which + +| Use | For | +| ------------------------------ | -------------------------------------------------------------------------- | +| `this.progress(n, total, msg)` | Quantitative progress — clients render a progress bar | +| `this.notify(msg, level?)` | Qualitative updates — log lines, status, warnings | +| `this.mark(stage)` | Server-side only — surfaced in logs/metrics/traces, not sent to the client | + +## Don't + +- Don't call `this.progress` in a tight loop with no `total`. Without `total`, clients can't render a meaningful bar. +- Don't push a `this.notify` per loop iteration. That's progress. Use `this.progress` for granular updates and reserve `this.notify` for events the user genuinely needs to see (failures, warnings, milestones). diff --git a/libs/skills/catalog/create-tool/examples/19-tool-with-elicitation.md b/libs/skills/catalog/create-tool/examples/19-tool-with-elicitation.md new file mode 100644 index 000000000..65bab36fa --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/19-tool-with-elicitation.md @@ -0,0 +1,99 @@ +--- +name: 19-tool-with-elicitation +level: advanced +description: 'Tool that pauses mid-execution to ask the user for confirmation + extra input via `this.elicit(...)` — the safe pattern for destructive or expensive actions.' +tags: [elicitation, this.elicit, destructive-action, confirmation] +features: + - 'Calling `this.elicit(message, { fieldSchema })` to request interactive input mid-`execute()`' + - 'Branching on `result.action` — `accept` / `decline` / `cancel` — and matching the early returns against `outputSchema`' + - 'Pairing elicitation with `annotations.destructiveHint: true` so clients know to render the confirmation prominently' + - "Requiring `elicitation: { enabled: true }` at the `@FrontMcp({...})` server level — and what fails when it isn't" +--- + +# Tool With Elicitation + +Tool that pauses mid-execution to ask the user for confirmation + extra input via `this.elicit(...)` — the safe pattern for destructive or expensive actions. + +For destructive or expensive actions, elicitation is the safe pattern. The tool starts, pauses, asks the user "are you sure? what reason?", and finishes (or cancels) based on the answer. + +> Requires `elicitation: { enabled: true }` on `@FrontMcp({...})`. Without it, every `this.elicit(...)` throws `ElicitationDisabledError` at runtime. + +## Code + +```typescript +// src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; + +@FrontMcp({ + info: { name: 'demo', version: '1.0.0' }, + apps: [MainApp], + elicitation: { enabled: true }, // ← must be enabled for this.elicit to work +}) +export default class DemoServer {} +``` + +```typescript +// src/apps/main/tools/delete-user.tool.ts +import { PublicMcpError, Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { USER_SERVICE } from '../tokens'; + +const inputSchema = { userId: z.string().describe('User ID to delete') }; +const outputSchema = z.discriminatedUnion('outcome', [ + z.object({ outcome: z.literal('deleted'), userId: z.string(), reason: z.string().optional() }), + z.object({ outcome: z.literal('cancelled'), userId: z.string(), reason: z.string() }), +]); + +@Tool({ + name: 'delete_user', + description: 'Delete a user account — requires explicit confirmation', + inputSchema, + outputSchema, + annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: false }, +}) +export class DeleteUserTool extends ToolContext { + async execute(input: { userId: string }) { + const users = this.get(USER_SERVICE); + const user = await users.findById(input.userId); + if (!user) this.fail(new PublicMcpError(`No such user: ${input.userId}`)); + + const elicited = await this.elicit(`Permanently delete ${user.email}? This cannot be undone.`, { + confirm: z.boolean().describe('Set to true to confirm'), + reason: z.string().optional().describe('Reason for the audit log'), + }); + + if (elicited.action === 'cancel') { + return { outcome: 'cancelled' as const, userId: input.userId, reason: 'User closed prompt' }; + } + if (elicited.action === 'decline' || !elicited.data.confirm) { + return { outcome: 'cancelled' as const, userId: input.userId, reason: 'User declined' }; + } + + await users.delete(input.userId, { reason: elicited.data.reason }); + return { outcome: 'deleted' as const, userId: input.userId, reason: elicited.data.reason }; + } +} +``` + +## What This Demonstrates + +- Calling `this.elicit(message, { fieldSchema })` to request interactive input mid-`execute()` +- Branching on `result.action` — `accept` / `decline` / `cancel` — and matching the early returns against `outputSchema` +- Pairing elicitation with `annotations.destructiveHint: true` so clients know to render the confirmation prominently +- Requiring `elicitation: { enabled: true }` at the `@FrontMcp({...})` server level — and what fails when it isn't + +## `result.action` matrix + +| Action | When | Always check `result.data`? | +| ----------- | ----------------------------------------- | -------------------------------------------- | +| `'accept'` | User filled the form and submitted | Yes — `result.data` is typed from the schema | +| `'decline'` | User clicked decline / no | No — `data` is absent | +| `'cancel'` | User closed the prompt without responding | No — `data` is absent | + +The Zod schema you pass to `this.elicit` defines the `result.data` type when action is `'accept'`. + +## Early returns must match `outputSchema` + +The `cancelled` branch returns `{ outcome: 'cancelled', userId, reason }` which matches the `z.discriminatedUnion` outputSchema. If `outputSchema` were `z.object({ deleted: z.boolean() })`, you'd return `{ deleted: false }` instead. + +The framework validates the return regardless of how you got there — early elicitation returns are no exception. diff --git a/libs/skills/catalog/create-tool/examples/20-tool-with-annotations.md b/libs/skills/catalog/create-tool/examples/20-tool-with-annotations.md new file mode 100644 index 000000000..a667cc458 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/20-tool-with-annotations.md @@ -0,0 +1,125 @@ +--- +name: 20-tool-with-annotations +level: basic +description: 'Four tools showing the standard annotation combinations — read-only query, destructive delete, send-email side-effecting, external-API search — and the client behavior each combination opts into.' +tags: [annotations, readOnlyHint, destructiveHint, idempotentHint, openWorldHint] +features: + - 'Setting `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` to opt into specific client behaviors (auto-retry, confirmation gating, parallelization)' + - 'Providing a human-readable `title` that overrides the snake_case `name` in client UIs' + - "Picking the conservative defaults when the annotations aren't obvious (omitting fields is safer than guessing)" + - 'Why `send_email` sets `idempotentHint: false` (each call sends a new email) while `delete_user` sets it to `true` (deleting twice still leaves the user deleted)' +--- + +# Tool With Annotations + +Four tools showing the standard annotation combinations — read-only query, destructive delete, send-email side-effecting, external-API search — and the client behavior each combination opts into. + +`annotations` are advisory but the client uses them to decide whether to gate, parallelize, or retry. Four canonical combinations cover most tools. + +## Code + +```typescript +// src/apps/main/tools/annotations.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +// 1. Read-only query — safe to call freely, parallelizable, auto-retryable +@Tool({ + name: 'search_users', + description: 'Search users by name or email', + inputSchema: { query: z.string(), limit: z.number().int().min(1).max(100).default(10) }, + outputSchema: { users: z.array(z.object({ id: z.string(), email: z.string().email() })) }, + annotations: { + title: 'Search users', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, // local DB only + }, +}) +export class SearchUsersTool extends ToolContext { + async execute(_input: { query: string; limit: number }) { + return { users: [] }; + } +} + +// 2. Destructive admin action — confirmation gated, retryable +@Tool({ + name: 'delete_user', + description: 'Permanently delete a user account', + inputSchema: { userId: z.string() }, + outputSchema: { deleted: z.boolean() }, + annotations: { + title: 'Delete user', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, // deleting twice leaves the user deleted — safe to retry + openWorldHint: false, + }, +}) +export class DeleteUserTool extends ToolContext { + async execute(_input: { userId: string }) { + return { deleted: true }; + } +} + +// 3. Send-email — side effect, NOT idempotent, external service +@Tool({ + name: 'send_email', + description: 'Send an email via SMTP', + inputSchema: { to: z.string().email(), subject: z.string(), body: z.string() }, + outputSchema: { messageId: z.string() }, + annotations: { + title: 'Send email', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, // each call sends a NEW email — don't auto-retry blindly + openWorldHint: true, + }, +}) +export class SendEmailTool extends ToolContext { + async execute(_input: { to: string; subject: string; body: string }) { + return { messageId: `<${crypto.randomUUID()}@example.com>` }; + } +} + +// 4. External-API search — read-only but talks to the open world +@Tool({ + name: 'web_search', + description: 'Search the web via an external search API', + inputSchema: { query: z.string() }, + outputSchema: { results: z.array(z.object({ title: z.string(), url: z.string().url() })) }, + annotations: { + title: 'Web search', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, // calls external service + }, +}) +export class WebSearchTool extends ToolContext { + async execute(_input: { query: string }) { + return { results: [] }; + } +} +``` + +## What This Demonstrates + +- Setting `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` to opt into specific client behaviors (auto-retry, confirmation gating, parallelization) +- Providing a human-readable `title` that overrides the snake_case `name` in client UIs +- Picking the conservative defaults when the annotations aren't obvious (omitting fields is safer than guessing) +- Why `send_email` sets `idempotentHint: false` (each call sends a new email) while `delete_user` sets it to `true` (deleting twice still leaves the user deleted) + +## How clients use each annotation + +| Annotation | Common client behavior | +| ----------------------- | ------------------------------------------------------- | +| `readOnlyHint: true` | May parallelize calls; doesn't gate behind confirmation | +| `destructiveHint: true` | Shows "are you sure?" before execution | +| `idempotentHint: true` | Auto-retries on transient failures | +| `openWorldHint: false` | Hints that the tool is offline-safe | +| `title` | Replaces the snake_case `name` in the UI | + +## When to omit + +If the right value isn't obvious, omit. The defaults are conservative — `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. Clients will gate and not parallelize, which is the safe wrong choice. diff --git a/libs/skills/catalog/create-tool/examples/21-tool-with-availability-constraints.md b/libs/skills/catalog/create-tool/examples/21-tool-with-availability-constraints.md new file mode 100644 index 000000000..a74227743 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/21-tool-with-availability-constraints.md @@ -0,0 +1,107 @@ +--- +name: 21-tool-with-availability-constraints +level: advanced +description: 'Three tools showing the `availableWhen` axes — macOS-only OS gate, production+Node runtime gate, and a `surface` gate that allows agent + job invocation but blocks direct MCP-client calls.' +tags: [availableWhen, os, runtime, surface, EntryUnavailableError] +features: + - "Restricting a tool to macOS with `availableWhen: { os: ['darwin'] }`" + - "Composing constraints — `runtime: ['node']` AND `env: ['production']` — both must match for the tool to be available" + - 'Using the `surface` axis to expose an internal tool to agents and jobs while hiding it from direct user invocation' + - 'Knowing what happens on mismatch — `EntryUnavailableError` (`-32099`) with `data.missingAxes` so clients show the right "not available here" reason' +--- + +# Tool With Availability Constraints + +Three tools showing the `availableWhen` axes — macOS-only OS gate, production+Node runtime gate, and a `surface` gate that allows agent + job invocation but blocks direct MCP-client calls. + +`availableWhen` is a hard registry-level constraint. Tools that don't match are filtered out of `tools/list` AND blocked from execution. Three real-world axes: + +## Code + +```typescript +// src/apps/main/tools/availability.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +// 1. macOS-only — uses Apple-specific APIs +@Tool({ + name: 'apple_notes_search', + description: 'Search Apple Notes via the native bridge', + inputSchema: { query: z.string() }, + outputSchema: { notes: z.array(z.object({ id: z.string(), title: z.string() })) }, + availableWhen: { os: ['darwin'] }, +}) +export class AppleNotesSearchTool extends ToolContext { + async execute(_input: { query: string }) { + return { notes: [] }; + } +} + +// 2. Production-only AND Node runtime — uses production-specific deploy infra +@Tool({ + name: 'deploy_to_production', + description: 'Deploy a service to production', + inputSchema: { service: z.string(), version: z.string() }, + outputSchema: { deploymentId: z.string() }, + availableWhen: { runtime: ['node'], env: ['production'] }, + annotations: { destructiveHint: true, idempotentHint: false }, +}) +export class DeployToProductionTool extends ToolContext { + async execute(_input: { service: string; version: string }) { + return { deploymentId: 'd_42' }; + } +} + +// 3. Agent / job only — internal tool, blocked from direct MCP client invocation +@Tool({ + name: 'rotate_secrets', + description: 'Rotate the application signing keys', + inputSchema: {}, + outputSchema: { rotated: z.boolean() }, + availableWhen: { surface: ['agent', 'job'] }, // not 'mcp' — chat UIs can't call this directly + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, +}) +export class RotateSecretsTool extends ToolContext { + async execute() { + return { rotated: true }; + } +} +``` + +## What This Demonstrates + +- Restricting a tool to macOS with `availableWhen: { os: ['darwin'] }` +- Composing constraints — `runtime: ['node']` AND `env: ['production']` — both must match for the tool to be available +- Using the `surface` axis to expose an internal tool to agents and jobs while hiding it from direct user invocation +- Knowing what happens on mismatch — `EntryUnavailableError` (`-32099`) with `data.missingAxes` so clients show the right "not available here" reason + +## Axes (recap) + +| Axis | Values | +| ------------ | ----------------------------------------------------------------------------------------------- | +| `os` | `'darwin'`, `'linux'`, `'win32'` | +| `runtime` | `'node'`, `'browser'`, `'edge'`, `'bun'`, `'deno'` | +| `deployment` | `'serverless'`, `'standalone'`, `'distributed'`, `'browser'` | +| `provider` | `'bare'`, `'docker'`, `'vercel'`, `'lambda'`, `'cloudflare'`, … | +| `target` | `'cli'`, `'node'`, `'vercel'`, `'lambda'`, `'cloudflare'`, … (set by `frontmcp build --target`) | +| `surface` | `'mcp'`, `'cli'`, `'agent'`, `'job'`, `'http-trigger'` — per-call axis | +| `env` | `'production'`, `'development'`, `'test'` | + +Multiple axes are AND-ed. Multiple values within an axis are OR-ed. + +## Why declarative beats imperative checks + +```typescript +// ❌ imperative — tool still appears in tools/list everywhere; users see "this won't work here" only at call time +async execute(input) { + if (!this.isPlatform('darwin')) this.fail(new PublicMcpError('macOS only')); + // … +} + +// ✅ declarative — tool is filtered out of tools/list on non-macOS servers; users never see it +@Tool({ + availableWhen: { os: ['darwin'] }, + // … +}) +``` + +The declarative form removes the tool from `tools/list` on non-matching servers — AI clients won't propose using it. Imperative checks leak the tool's existence to users who can't use it. diff --git a/libs/skills/catalog/create-tool/examples/22-tool-with-ui-html-template.md b/libs/skills/catalog/create-tool/examples/22-tool-with-ui-html-template.md new file mode 100644 index 000000000..0744b283d --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/22-tool-with-ui-html-template.md @@ -0,0 +1,93 @@ +--- +name: 22-tool-with-ui-html-template +level: intermediate +description: "Tool with an inline HTML function template — `ui: { template: (ctx) => '
' }` — for a quick widget that doesn't need a separate `.tsx` file." +tags: [ui, ui-widgets, html-template, escapeHtml, TemplateContext] +features: + - 'Adding a `ui:` block with a function template `(ctx: TemplateContext) => string`' + - 'Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type' + - "Always escaping user-controlled output with `ctx.helpers.escapeHtml(...)` so the widget can't XSS itself" + - 'Reading from `ctx.output` and `ctx.helpers` — the typed runtime context the template renderer hands you' +--- + +# Tool With Ui Html Template + +Tool with an inline HTML function template — `ui: { template: (ctx) => '
' }` — for a quick widget that doesn't need a separate `.tsx` file. + +For widgets that don't need React / state / interactivity, an inline function template is the simplest form. Read `ctx.output`, escape user-controlled fields, return an HTML string. + +## Code + +```typescript +// src/apps/main/tools/show-weather-card.tool.ts +import { Tool, ToolContext, z, type TemplateContext } from '@frontmcp/sdk'; + +const inputSchema = { city: z.string() }; +const outputSchema = { + city: z.string(), + temperatureF: z.number(), + conditions: z.string(), +}; +type In = { city: string }; +type Out = { city: string; temperatureF: number; conditions: string }; + +@Tool({ + name: 'show_weather_card', + description: 'Show current weather as a card', + inputSchema, + outputSchema, + ui: { + widgetDescription: 'Current weather card', + template: (ctx: TemplateContext) => ` +
+

${ctx.helpers.escapeHtml(ctx.output.city)}

+

${ctx.output.temperatureF}°F

+

${ctx.helpers.escapeHtml(ctx.output.conditions)}

+
+ `, + }, +}) +export class ShowWeatherCardTool extends ToolContext { + async execute(input: In): Promise { + return { city: input.city, temperatureF: 72, conditions: 'Sunny' }; + } +} +``` + +## What This Demonstrates + +- Adding a `ui:` block with a function template `(ctx: TemplateContext) => string` +- Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type +- Always escaping user-controlled output with `ctx.helpers.escapeHtml(...)` so the widget can't XSS itself +- Reading from `ctx.output` and `ctx.helpers` — the typed runtime context the template renderer hands you + +## Why annotate `ctx` explicitly + +`ui.template` is a union of multiple callable shapes (`TemplateBuilderFn | string | ((props: any) => any) | FileSource`). TypeScript can't pick a single contextual type for the arrow's parameter, so `template: (ctx) => …` fails under `strict` / `noImplicitAny` with TS7006: + +```text +Parameter 'ctx' implicitly has an 'any' type. +``` + +Two ways out: + +- Annotate `ctx: TemplateContext` (this example) — fastest fix for a small inline widget. +- Move the widget to a `.tsx` file and use the FileSource form (`{ file: widgetPath }`) — recommended for anything non-trivial. See [`23-tool-with-ui-filesource-tsx`](./23-tool-with-ui-filesource-tsx.md). + +## When function templates are the right choice + +- Tiny widget — a card, a table row, a status badge +- No state / interactivity +- No external CSS / fonts / scripts beyond what `escapeHtml` can produce + +Move to a `.tsx` FileSource widget the moment you reach for React, useState, event handlers, or anything beyond static markup. + +## What `ctx.helpers` includes + +| Helper | Purpose | +| ------------------------------ | ----------------------------------------------------------- | +| `escapeHtml(str)` | Escape HTML entities; returns `''` for null/undefined | +| `formatDate(date, format?)` | Locale-formatted date | +| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting | +| `uniqueId(prefix?)` | Deterministic unique ID for DOM elements | +| `jsonEmbed(data)` | Safely embed JSON in a ``) | diff --git a/libs/skills/catalog/create-tool/examples/23-tool-with-ui-filesource-tsx.md b/libs/skills/catalog/create-tool/examples/23-tool-with-ui-filesource-tsx.md new file mode 100644 index 000000000..82eebbff3 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/23-tool-with-ui-filesource-tsx.md @@ -0,0 +1,112 @@ +--- +name: 23-tool-with-ui-filesource-tsx +level: advanced +description: 'Tool with a `.tsx` widget in a separate file via the `FileSource` form — the recommended pattern for any React widget. Path anchored with `import.meta.url` so it survives any cwd.' +tags: [ui, ui-widgets, FileSource, tsx, import.meta.url, host-detect] +features: + - 'Pointing `template` at a sibling `.tsx` file via the `FileSource` form `{ file: ... }`' + - "Anchoring the path to the tool source with `fileURLToPath(new URL('./...widget.tsx', import.meta.url))` so `process.cwd()` doesn't matter" + - "Leaving `resourceMode` unset — the framework host-detects (`'inline'` for Claude, `'cdn'` for others)" + - "Naming the widget `*.widget.tsx` so the scaffolded `tsconfig.json`'s `exclude` keeps it out of the server typecheck" +--- + +# Tool With Ui Filesource Tsx + +Tool with a `.tsx` widget in a separate file via the `FileSource` form — the recommended pattern for any React widget. Path anchored with `import.meta.url` so it survives any cwd. + +For any React widget, FileSource is the right pattern. The widget lives in its own `.widget.tsx` file with its own React imports; the tool decorator just points at it. + +> **Prerequisite:** `@frontmcp/ui` installed at the same version as `@frontmcp/sdk`. Without it, server-side bundling fails — the framework injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`. + +## Code + +```typescript +// src/apps/main/tools/sales-chart/sales-chart.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { year: z.number().int().min(2000).max(2100) }; +export const outputSchema = { + year: z.number(), + monthly: z.array(z.object({ month: z.string(), revenueUsd: z.number() })), +}; +export type SalesChartInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type SalesChartOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/sales-chart/sales-chart.tool.ts +import { fileURLToPath } from 'node:url'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { inputSchema, outputSchema, type SalesChartInput, type SalesChartOutput } from './sales-chart.schema'; + +// Anchor the widget path to THIS source file — bare relative paths resolve +// against process.cwd() (issue #444), which fails in any non-trivial layout. +const widgetPath = fileURLToPath(new URL('./sales-chart.widget.tsx', import.meta.url)); + +@Tool({ + name: 'sales_chart', + description: 'Render a yearly sales bar chart', + inputSchema, + outputSchema, + ui: { + widgetDescription: 'Monthly revenue chart', + template: { file: widgetPath }, + // resourceMode is intentionally UNSET — framework host-detects: 'inline' for Claude + // (React bundled in, widget renders under Claude's CSP), 'cdn' for OpenAI / ChatGPT / + // Cursor / MCP Inspector (smaller payload from esm.sh). Issue #456. + hydrate: false, // SSR-only — dodges React error #418 in iframe sandboxes + }, +}) +export class SalesChartTool extends ToolContext { + async execute(input: SalesChartInput): Promise { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return { year: input.year, monthly: months.map((month, i) => ({ month, revenueUsd: 10_000 + i * 1_250 })) }; + } +} +``` + +```tsx +// src/apps/main/tools/sales-chart/sales-chart.widget.tsx +import { useEffect, useRef } from 'react'; + +type Props = { output: { year: number; monthly: Array<{ month: string; revenueUsd: number }> } }; + +export default function SalesChartWidget({ output }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) return; + // …simple bar chart, no external deps so it runs everywhere + ctx.fillStyle = '#3b82f6'; + output.monthly.forEach((m, i) => { + const barHeight = (m.revenueUsd / 25_000) * 280; + ctx.fillRect(i * 28 + 8, 300 - barHeight, 20, barHeight); + }); + }, [output]); + + return ( +
+

Sales — {output.year}

+ +
+ ); +} +``` + +## What This Demonstrates + +- Pointing `template` at a sibling `.tsx` file via the `FileSource` form `{ file: ... }` +- Anchoring the path to the tool source with `fileURLToPath(new URL('./...widget.tsx', import.meta.url))` so `process.cwd()` doesn't matter +- Leaving `resourceMode` unset — the framework host-detects (`'inline'` for Claude, `'cdn'` for others) +- Naming the widget `*.widget.tsx` so the scaffolded `tsconfig.json`'s `exclude` keeps it out of the server typecheck + +## Why these defaults matter + +- **`import.meta.url` anchoring** — relative paths in `FileSource` resolve against `process.cwd()`, not the tool file (#444). Running the server from a different directory breaks the widget at tool-call time. Anchoring fixes it once. +- **`resourceMode` unset** — leave it. The framework picks `'inline'` for Claude (React bundled into the widget — actually renders) and `'cdn'` for everyone else (smaller payload via esm.sh). Setting it explicitly only locks in one behavior across all clients. +- **`hydrate: false`** — default. React SSR output is static HTML; the bridge IIFE handles any interactivity. Enabling hydration creates React error #418 in Claude's iframe sandbox where the client-side render diverges from the SSR render. +- **`*.widget.tsx` naming** — the scaffolded `tsconfig.json` excludes `**/*.widget.tsx` from the server typecheck (#445). The widget compiles via uipack/esbuild at render time with its own React-aware config. diff --git a/libs/skills/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md b/libs/skills/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md new file mode 100644 index 000000000..86ae8c39e --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md @@ -0,0 +1,127 @@ +--- +name: 24-tool-with-ui-csp-and-bridge +level: advanced +description: 'Interactive tool widget that fetches from an allow-listed CSP origin and invokes another tool via `window.FrontMcpBridge.callTool` — the full pattern for live-data widgets that need cross-tool composition.' +tags: [ui, csp, widgetAccessible, FrontMcpBridge, interactive-widget] +features: + - "Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)" + - 'Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs' + - "Embedding initial data into the widget's inline ``)" + - 'Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback' +--- + +# Tool With Ui Csp And Bridge + +Interactive tool widget that fetches from an allow-listed CSP origin and invokes another tool via `window.FrontMcpBridge.callTool` — the full pattern for live-data widgets that need cross-tool composition. + +The advanced widget pattern. The tool's response renders a stock-quote card with a "Refresh" button that calls another tool through the bridge. CSP locks down outbound fetches; the bridge routes cross-tool calls to the right host adapter (OpenAI / Claude / direct) automatically. + +## Code + +```typescript +// src/apps/main/tools/get-quote/get-quote.tool.ts +import { PublicMcpError, Tool, ToolContext, z } from '@frontmcp/sdk'; + +const inputSchema = { symbol: z.string().regex(/^[A-Z]{1,5}$/) }; +const outputSchema = { symbol: z.string(), priceUsd: z.number(), asOf: z.string() }; + +@Tool({ + name: 'get_quote', + description: 'Get the latest stock price for a ticker symbol', + inputSchema, + outputSchema, +}) +export class GetQuoteTool extends ToolContext { + async execute(input: { symbol: string }) { + const res = await this.fetch(`https://api.market.example/quote/${input.symbol}`); + if (!res.ok) { + this.fail(new PublicMcpError(`Quote upstream returned ${res.status} ${res.statusText}`)); + } + const body = (await res.json()) as { price: number; ts: string }; + return { symbol: input.symbol, priceUsd: body.price, asOf: body.ts }; + } +} +``` + +```typescript +// src/apps/main/tools/show-quote/show-quote.tool.ts +import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from '@frontmcp/sdk'; + +const inputSchema = { symbol: z.string().regex(/^[A-Z]{1,5}$/) }; +const outputSchema = { symbol: z.string(), priceUsd: z.number(), asOf: z.string() }; +type In = { symbol: string }; +type Out = { symbol: string; priceUsd: number; asOf: string }; + +@Tool({ + name: 'show_quote', + description: 'Render a live quote widget for a ticker symbol', + inputSchema, + outputSchema, + ui: { + widgetDescription: 'Live stock quote with refresh', + widgetAccessible: true, // required for window.FrontMcpBridge.callTool + invocationStatus: { invoking: 'Fetching quote…', invoked: 'Quote loaded' }, + csp: { + // CSP applies to the widget iframe — only allow fetches to our own market-data API. + // Framework emits this on the resource's _meta.ui.csp so Claude honors it (#455). + connectDomains: ['https://api.market.example'], + }, + template: (ctx: TemplateContext) => { + const initial = ctx.helpers.jsonEmbed(ctx.output); + return ` +
+

${ctx.helpers.escapeHtml(ctx.output.symbol)}

+

$${ctx.output.priceUsd.toFixed(2)}

+

As of ${ctx.helpers.escapeHtml(ctx.output.asOf)}

+ + +
+ `; + }, + }, +}) +export class ShowQuoteTool extends ToolContext { + async execute(input: In): Promise { + const res = await this.fetch(`https://api.market.example/quote/${input.symbol}`); + if (!res.ok) { + this.fail(new PublicMcpError(`Quote upstream returned ${res.status} ${res.statusText}`)); + } + const body = (await res.json()) as { price: number; ts: string }; + return { symbol: input.symbol, priceUsd: body.price, asOf: body.ts }; + } +} +``` + +## What This Demonstrates + +- Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455) +- Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs +- Embedding initial data into the widget's inline ``) +- Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback + +## Why these choices + +- **`widgetAccessible: true`** — required for `window.FrontMcpBridge.callTool`. Without it, the bridge is read-only (the widget can read `getToolInput` / `getToolOutput` but can't invoke tools). +- **`csp.connectDomains`** — limits what the widget can `fetch` to. Without a CSP, the host's default applies (which may block everything in Claude). With `connectDomains: ['https://api.market.example']`, only that origin is reachable. +- **`window.FrontMcpBridge.callTool` not `window.openai.callTool`** — the bridge handles host detection. `window.openai.*` works on OpenAI Apps SDK but breaks everywhere else. +- **`jsonEmbed` not `JSON.stringify`** — `JSON.stringify` doesn't escape `` and can break out of the inline script tag. `jsonEmbed` does. diff --git a/libs/skills/catalog/create-tool/examples/25-tool-handing-off-to-job.md b/libs/skills/catalog/create-tool/examples/25-tool-handing-off-to-job.md new file mode 100644 index 000000000..9ac97962b --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/25-tool-handing-off-to-job.md @@ -0,0 +1,143 @@ +--- +name: 25-tool-handing-off-to-job +level: advanced +description: 'Thin tool that validates input and enqueues a `@Job` to do the heavy lifting — the right pattern for any operation that takes more than a few seconds.' +tags: [composition, jobs, job-handoff, hideFromDiscovery] +features: + - 'Splitting a long-running operation into a thin tool (validates, enqueues, returns a tracking handle) plus a `@Job` (does the work)' + - Returning the job ID + status URL from the tool so the client can poll or stream updates + - "Using `availableWhen: { surface: ['mcp', 'agent'] }` on the tool while leaving the heavy `@Job` invisible to direct invocation" + - 'Why this beats running the heavy work inside `execute()` (avoids tool-call timeout limits, lets the job retry independently)' +--- + +# Tool Handing Off To Job + +Thin tool that validates input and enqueues a `@Job` to do the heavy lifting — the right pattern for any operation that takes more than a few seconds. + +Tools are for synchronous-ish interactions (≤30s end-to-end). Anything longer should run in a `@Job`, with a thin tool to kick it off. + +## Code + +```typescript +// src/apps/main/jobs/export-data.job.ts +import { Job, JobContext } from '@frontmcp/sdk'; + +@Job({ + name: 'export_data', + description: 'Export a dataset to CSV', + retry: { maxAttempts: 3, backoff: 'exponential' }, +}) +export class ExportDataJob extends JobContext { + async run(args: { datasetId: string; format: 'csv' | 'json' }) { + await this.progress(0, 100, 'Loading dataset…'); + const rows = await this.loadDataset(args.datasetId); + + await this.progress(50, 100, 'Serializing…'); + const blob = await this.serialize(rows, args.format); + + await this.progress(95, 100, 'Uploading…'); + const downloadUrl = await this.upload(blob); + + await this.progress(100, 100, 'Done'); + return { downloadUrl, rowCount: rows.length }; + } + + private async loadDataset(_id: string) { + return [{ a: 1 }, { a: 2 }]; + } + private async serialize(_rows: unknown[], _fmt: string) { + return Buffer.alloc(0); + } + private async upload(_blob: Buffer) { + return 'https://exports.example/x.csv'; + } +} +``` + +```typescript +// src/apps/main/tools/export-data.tool.ts +import { ResourceNotFoundError, Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { DATASETS, JOBS } from '../tokens'; + +const inputSchema = { + datasetId: z.string().uuid(), + format: z.enum(['csv', 'json']).default('csv'), +}; +const outputSchema = { + jobId: z.string(), + statusUrl: z.string().url(), + estimatedSeconds: z.number().int(), +}; + +@Tool({ + name: 'export_data', + description: 'Start a dataset export — returns a job handle the client can poll', + inputSchema, + outputSchema, + availableWhen: { surface: ['mcp', 'agent'] }, + annotations: { idempotentHint: false, openWorldHint: false }, +}) +export class ExportDataTool extends ToolContext { + async execute(input: { datasetId: string; format: 'csv' | 'json' }) { + // 1. AUTHORIZE BEFORE ENQUEUEING. The job inherits this caller's auth scope at + // enqueue time, so an unchecked tool here would let any caller export any + // dataset they happen to know the ID of. Concretely: look up the dataset + // scoped to the caller's tenant / user identity, fail fast if missing. + const datasets = this.get(DATASETS); + const userId = this.context.authInfo.userId; + const tenantId = this.context.authInfo.tenantId; + const dataset = await datasets.findForUser(input.datasetId, { userId, tenantId }); + if (!dataset) { + this.fail(new ResourceNotFoundError(`dataset:${input.datasetId}`)); + } + + // 2. Enqueue the job — runs in the caller's auth scope (the job's JobContext + // re-checks tenant / user access inside `run()` as a defense-in-depth). + const jobs = this.get(JOBS); + const job = await jobs.enqueue('export_data', input, { userId, tenantId }); + + // 3. Return a handle the client can poll / stream. + return { + jobId: job.id, + statusUrl: `/jobs/${job.id}`, + estimatedSeconds: 30, + }; + } +} +``` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { ExportDataJob } from './jobs/export-data.job'; +import { ExportDataTool } from './tools/export-data.tool'; + +@App({ + name: 'main', + tools: [ExportDataTool], + jobs: [ExportDataJob], +}) +export class MainApp {} +``` + +## What This Demonstrates + +- Splitting a long-running operation into a thin tool (validates, enqueues, returns a tracking handle) plus a `@Job` (does the work) +- Returning the job ID + status URL from the tool so the client can poll or stream updates +- Using `availableWhen: { surface: ['mcp', 'agent'] }` on the tool while leaving the heavy `@Job` invisible to direct invocation +- Why this beats running the heavy work inside `execute()` (avoids tool-call timeout limits, lets the job retry independently) + +## Why hand off + +| Inside `execute()` | Inside a `@Job` | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Capped by the tool's `timeout` (typically ≤30s) | Designed for minutes / hours | +| One attempt — fails on transient errors | Retry config — `maxAttempts`, `backoff` | +| Progress emits to the current MCP session only | Job runs independently of the session — can survive disconnects (with a persistent job store) | +| Synchronous from the client's POV | Asynchronous — client polls `statusUrl` or subscribes to a channel | + +If the work can take >10 seconds OR needs to survive a session drop OR needs retry-on-failure, it belongs in a job. + +See `create-job` for the full job surface — retry, progress, batching, permission scopes. diff --git a/libs/skills/catalog/create-tool/examples/26-tool-with-resource-link-output.md b/libs/skills/catalog/create-tool/examples/26-tool-with-resource-link-output.md new file mode 100644 index 000000000..d18671b04 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/26-tool-with-resource-link-output.md @@ -0,0 +1,93 @@ +--- +name: 26-tool-with-resource-link-output +level: advanced +description: "Tool returning `outputSchema: 'resource_link'` — the URI is sent to the client; the client fetches the body via `resources/read`. The right pattern for large or cacheable payloads." +tags: [output-schema, resource_link, large-payload, caching] +features: + - "Returning `outputSchema: 'resource_link'` from a tool — `{ uri }` only, body fetched separately" + - "Pairing the tool with a matching `@Resource({ uri: 'export://{exportId}.csv' })` URI template that resolves to the actual body" + - "When `'resource_link'` beats `'image'` / `'audio'` / a raw byte response (large payloads, cacheable URIs, deferred fetch)" + - 'Cross-linking to the `create-resource` skill for the URI-template resource on the other end' +--- + +# Tool With Resource Link Output + +Tool returning `outputSchema: 'resource_link'` — the URI is sent to the client; the client fetches the body via `resources/read`. The right pattern for large or cacheable payloads. + +When the tool's output is large (>1MB) or cacheable, return a `'resource_link'` instead of inlining the bytes. The tool returns just `{ uri }`; the client decides when to fetch the body via `resources/read`. + +## Code + +```typescript +// src/apps/main/resources/export.resource.ts +// (Lives in this app — see the create-resource skill for the full URI-template surface) +import { Resource, ResourceContext } from '@frontmcp/sdk'; + +import { EXPORTS } from '../tokens'; + +@Resource({ + uri: 'export://{exportId}.csv', + description: 'Generated export CSV', +}) +export class ExportCsvResource extends ResourceContext<{ exportId: string }> { + async read(params: { exportId: string }) { + const exports = this.get(EXPORTS); + const csv = await exports.loadCsv(params.exportId); // returns Buffer or stream + return { + contents: [{ uri: `export://${params.exportId}.csv`, mimeType: 'text/csv', blob: csv.toString('base64') }], + }; + } +} +``` + +```typescript +// src/apps/main/tools/start-export.tool.ts +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { EXPORTS } from '../tokens'; + +const inputSchema = { + datasetId: z.string().uuid(), +}; +const outputSchema = 'resource_link'; + +@Tool({ + name: 'start_export', + description: 'Generate an export and return a resource link the client can read', + inputSchema, + outputSchema, + annotations: { idempotentHint: false }, +}) +export class StartExportTool extends ToolContext { + async execute(input: { datasetId: string }) { + const exports = this.get(EXPORTS); + const exportId = await exports.create(input.datasetId); + + // Return JUST the URI. The client fetches the body via resources/read. + return { uri: `export://${exportId}.csv` }; + } +} +``` + +## What This Demonstrates + +- Returning `outputSchema: 'resource_link'` from a tool — `{ uri }` only, body fetched separately +- Pairing the tool with a matching `@Resource({ uri: 'export://{exportId}.csv' })` URI template that resolves to the actual body +- When `'resource_link'` beats `'image'` / `'audio'` / a raw byte response (large payloads, cacheable URIs, deferred fetch) +- Cross-linking to the `create-resource` skill for the URI-template resource on the other end + +## `'resource_link'` vs `'resource'` vs `'image'` / `'audio'` + +| Output | When | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `'resource_link'` | URI only. Best for large payloads (>1MB), cacheable content, deferred-fetch UX. Tool stays cheap; client fetches on demand. | +| `'resource'` | URI + inline content in one response. Best when the client always needs the body immediately (small-to-medium size). | +| `'image'` / `'audio'` | Inlined base64. Simplest for small media (≤1MB). No URI involved. | + +## Why split tool + resource + +- **Tool runs fast** — just generates the URI; doesn't materialize the whole payload in the response. +- **Client caches by URI** — repeated requests for `export://abc.csv` hit the client's cache; the tool only runs again if the URI changes. +- **Resource lifecycle is independent** — you can expire resources, regenerate them on demand, version them by URI suffix. + +See the `create-resource` skill for URI templates, parameter validation, multi-content reads, and binary vs text resources. diff --git a/libs/skills/catalog/create-tool/examples/27-tool-with-examples-metadata.md b/libs/skills/catalog/create-tool/examples/27-tool-with-examples-metadata.md new file mode 100644 index 000000000..bdd9db4f0 --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/27-tool-with-examples-metadata.md @@ -0,0 +1,91 @@ +--- +name: 27-tool-with-examples-metadata +level: basic +description: 'Tool with the `examples: [...]` field on `@Tool({...})` — concrete input (and optional expected output) examples surfaced in `tools/list` so AI clients can render them as quick-action suggestions.' +tags: [examples-metadata, discovery, tools-list] +features: + - 'Adding `examples: [{ description, input, output? }]` to `@Tool({...})` so AI clients see canned invocations' + - 'Writing realistic example inputs so the description in `tools/list` is concrete, not abstract' + - 'Including `output?` for examples where showing the expected result helps client UX (preview tiles, etc.)' + - 'Why `examples` are advisory metadata — never relied on by the framework, only surfaced to discovery' +--- + +# Tool With Examples Metadata + +Tool with the `examples: [...]` field on `@Tool({...})` — concrete input (and optional expected output) examples surfaced in `tools/list` so AI clients can render them as quick-action suggestions. + +The `examples` field is purely advisory — AI clients use it to surface canned invocations in their UI (quick-action buttons, prompt suggestions, tool-picker previews). Use it for any tool that benefits from concrete usage hints. + +## Code + +```typescript +// src/apps/main/tools/convert-currency.tool.ts +import { Tool, ToolContext, ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +const inputSchema = { + amount: z.number().positive(), + from: z.string().regex(/^[A-Z]{3}$/, 'ISO 4217 code, e.g. USD'), + to: z.string().regex(/^[A-Z]{3}$/), +}; +const outputSchema = { converted: z.number(), rate: z.number(), asOf: z.string() }; + +@Tool({ + name: 'convert_currency', + description: 'Convert an amount from one currency to another', + inputSchema, + outputSchema, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + examples: [ + { + description: 'Convert 100 USD to EUR', + input: { amount: 100, from: 'USD', to: 'EUR' }, + output: { converted: 91.4, rate: 0.914, asOf: '2026-05-29T12:00:00Z' }, + }, + { + description: 'Convert 1,000,000 GBP to JPY', + input: { amount: 1_000_000, from: 'GBP', to: 'JPY' }, + }, + { + description: 'Convert 50 EUR to USD', + input: { amount: 50, from: 'EUR', to: 'USD' }, + }, + ], +}) +export class ConvertCurrencyTool extends ToolContext { + async execute( + input: ToolInputOf<{ inputSchema: typeof inputSchema }>, + ): Promise> { + const rate = await this.fetchRate(input.from, input.to); + return { converted: +(input.amount * rate).toFixed(2), rate, asOf: new Date().toISOString() }; + } + + private async fetchRate(_from: string, _to: string): Promise { + return 0.914; + } +} +``` + +## What This Demonstrates + +- Adding `examples: [{ description, input, output? }]` to `@Tool({...})` so AI clients see canned invocations +- Writing realistic example inputs so the description in `tools/list` is concrete, not abstract +- Including `output?` for examples where showing the expected result helps client UX (preview tiles, etc.) +- Why `examples` are advisory metadata — never relied on by the framework, only surfaced to discovery + +## Where examples show up + +- **`tools/list` MCP response** — clients can render them as quick-action chips, suggestion lists, tool-picker previews. +- **`frontmcp skills list` CLI** — when the tool is documented in a skill catalog. +- **Generated docs** — `frontmcp build --target sdk` includes them in the published API. + +## When to include `output?` + +- ✅ When showing the expected output makes the tool's purpose clearer at a glance. +- ✅ For UI clients that render result previews — knowing the shape lets them lay out the chip / card before the user clicks. +- ❌ For tools where the output is highly variable / live data (e.g. `web_search` results). Just show the input. + +## Don't + +- Don't put sensitive example data — `examples` are public. Use synthetic IDs (`u_1`), test addresses (`@example.com`), demo amounts. +- Don't pad with low-value examples just to hit a count. 2–4 well-chosen examples beats 20 trivial ones. +- Don't rely on `examples` for documentation — they're advisory hints, not formal docs. Put substantive guidance in `description`. diff --git a/libs/skills/catalog/create-tool/references/annotations.md b/libs/skills/catalog/create-tool/references/annotations.md new file mode 100644 index 000000000..9b326f3e4 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/annotations.md @@ -0,0 +1,96 @@ +--- +name: annotations +description: readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title — behavioral hints for clients. +--- + +# `annotations` + +Optional behavioral hints on `@Tool({...})`. AI clients use them to decide whether to gate the call behind a confirmation dialog, parallelize calls, retry on failure, etc. + +```typescript +@Tool({ + name: 'delete_user', + description: 'Delete a user account', + inputSchema, + outputSchema, + annotations: { + title: 'Delete user', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, +}) +``` + +## Fields + +| Field | Type | Default | Meaning | +| ----------------- | --------- | ----------------------------------- | ------------------------------------------------------------------------------ | +| `title` | `string` | — | Human-readable display name for client UIs (overrides `name` for presentation) | +| `readOnlyHint` | `boolean` | `false` | Tool only reads — no side effects. Safe to call freely. | +| `destructiveHint` | `boolean` | `true` (when `readOnlyHint: false`) | Tool may delete or overwrite. Clients usually trigger a confirmation. | +| `idempotentHint` | `boolean` | `false` | Repeated calls with same input produce the same result. Safe to retry. | +| `openWorldHint` | `boolean` | `true` | Tool interacts with external services / network. `false` = local-only. | + +## How clients use them + +| Annotation | Common client behavior | +| ----------------------- | ------------------------------------------------------------------ | +| `readOnlyHint: true` | Tool may run in parallel with other reads; no confirmation needed | +| `destructiveHint: true` | Confirmation dialog before invocation; "are you sure?" | +| `idempotentHint: true` | Auto-retry on transient failures | +| `openWorldHint: false` | Hint that the tool is offline-safe | +| `title` | Shown in tool pickers and history instead of the snake_case `name` | + +## Common combinations + +```typescript +// Read-only query tool — safe, retryable +annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} + +// Destructive admin action +annotations: { + destructiveHint: true, + idempotentHint: true, // deleting twice still leaves the thing deleted + openWorldHint: false, +} + +// Send an email +annotations: { + readOnlyHint: false, + destructiveHint: false, // not destroying, just sending + idempotentHint: false, // each call sends a new email + openWorldHint: true, +} + +// External API search +annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +} +``` + +## Don't lie to the client + +Annotations are advisory but the client trusts them: + +- Don't set `readOnlyHint: true` on a tool that writes — clients may parallelize it. +- Don't set `idempotentHint: true` on a tool that has incremental side effects — clients may retry and double up. +- Don't omit `destructiveHint: true` on a delete — users may not get the confirmation they need. + +## When to omit annotations entirely + +For ambiguous or non-obvious cases, omit. The defaults (`readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`) are the **conservative** assumption — clients will gate and not parallelize, which is the safe wrong choice if you're unsure. + +## See also + +- [`20-tool-with-annotations`](../examples/20-tool-with-annotations.md) +- [`decorator-options.md`](./decorator-options.md) diff --git a/libs/skills/catalog/create-tool/references/auth-providers.md b/libs/skills/catalog/create-tool/references/auth-providers.md new file mode 100644 index 000000000..818a5ae3f --- /dev/null +++ b/libs/skills/catalog/create-tool/references/auth-providers.md @@ -0,0 +1,137 @@ +--- +name: auth-providers +description: authProviders shorthand vs full mapping, scopes, alias, credential vault basics. +--- + +# `authProviders` + +Declare which OAuth (or static-credential) providers a tool requires. Credentials are loaded **before** `execute()` runs — by the time your code runs, the headers / tokens are already available via `this.authProviders.headers(name)`. + +## String shorthand (single required provider) + +```typescript +@Tool({ + name: 'create_issue', + description: 'Create a GitHub issue', + inputSchema, + outputSchema, + authProviders: ['github'], +}) +class CreateIssueTool extends ToolContext { + async execute(input: CreateIssueInput) { + const headers = await this.authProviders.headers('github'); + const response = await this.fetch('https://api.github.com/repos/.../issues', { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ title: input.title, body: input.body }), + }); + return response.json(); + } +} +``` + +`headers('github')` returns `{ Authorization: 'Bearer …' }` (or similar, depending on the provider). The framework handles refresh, expiration, and storage. + +## Full mapping form + +For scopes, required-vs-optional, or aliases: + +```typescript +@Tool({ + name: 'deploy_app', + description: 'Deploy a service to cloud', + inputSchema, + outputSchema, + authProviders: [ + { name: 'github', required: true, scopes: ['repo', 'workflow'] }, + { name: 'aws', required: false, alias: 'cloud' }, // optional; injected as `cloud` + ], +}) +class DeployAppTool extends ToolContext { + async execute(input: DeployInput) { + const githubHeaders = await this.authProviders.headers('github'); + // `cloud` is the alias for the optional `aws` provider: + const cloudHeaders = (await this.authProviders.tryHeaders('cloud')) ?? null; + // … + } +} +``` + +### Fields + +| Field | Type | Default | Meaning | +| ---------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | — | Must match a registered `@AuthProvider` on the server | +| `required` | `boolean` | `true` | If `true`, the tool fails before `execute()` runs when no credentials are available. If `false`, the call proceeds; check via `this.authProviders.tryHeaders` | +| `scopes` | `string[]` | — | Required OAuth scopes. The framework triggers incremental auth if the session lacks them | +| `alias` | `string` | = `name` | Local name for the provider — useful when two tools want the same provider under different labels | + +## When auth is missing + +For a `required: true` provider with no credentials: + +1. The framework throws `AuthRequiredError` BEFORE `execute()` runs. +2. The client receives an MCP error with code `-32001` and `data.authUrl` pointing at the OAuth start URL. +3. The user completes the OAuth flow. +4. The client retries the tool call. + +This is handled by the framework — you don't write any of this in `execute()`. + +## Reading auth in execute() + +Two patterns: + +### A. Pre-formatted headers (most common) + +```typescript +const headers = await this.authProviders.headers('github'); +const response = await this.fetch(url, { headers }); +``` + +`headers(name)` throws if creds aren't available (only safe to call inside a tool that declared the provider as `required: true`). + +`tryHeaders(name)` returns `Headers | null` — for `required: false` providers. + +### B. Raw token (for non-HTTP transports — gRPC, WebSocket, custom) + +```typescript +const token = await this.authProviders.token('github'); +// token: { value: string, type: 'bearer' | 'basic' | … } +``` + +### C. Full credential record (vault access) + +```typescript +const creds = await this.authProviders.get('github'); +// { accessToken, refreshToken?, expiresAt?, scopes, … } +``` + +Use the highest-level API that works (headers > token > full record) — the framework can short-circuit refresh / vault round-trips for the simpler APIs. + +## Credential vault + +For session-specific secrets the user types in (vs OAuth flows), use the credential vault: + +```typescript +@Tool({ + name: 'send_to_slack', + authProviders: ['slack-webhook'], // a vault-backed provider +}) +class SendToSlackTool extends ToolContext { + async execute(input: { message: string }) { + const headers = await this.authProviders.headers('slack-webhook'); + // headers contains the webhook URL the user pasted in earlier: + // { 'x-slack-webhook-url': 'https://hooks.slack.com/services/…' } + // … + } +} +``` + +The vault is encrypted at rest (per-session AES-256-GCM key) and never leaves the server. See the `auth` skill for vault setup, OAuth provider registration, and the credential UI. + +## See also + +- [`13-tool-with-single-auth-provider`](../examples/13-tool-with-single-auth-provider.md) +- [`14-tool-with-multiple-auth-providers`](../examples/14-tool-with-multiple-auth-providers.md) +- [`15-tool-with-credential-vault`](../examples/15-tool-with-credential-vault.md) +- `auth` skill — provider registration, vault, OAuth flows diff --git a/libs/skills/catalog/create-tool/references/availability.md b/libs/skills/catalog/create-tool/references/availability.md new file mode 100644 index 000000000..dcfb0b458 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/availability.md @@ -0,0 +1,106 @@ +--- +name: availability +description: availableWhen axes (os / runtime / deployment / provider / target / surface / env), missingAxes, isPlatform. +--- + +# `availableWhen` — registry-level availability constraints + +`availableWhen` is a **hard registry-level constraint** evaluated at server boot. Tools that don't match are filtered out of `tools/list` AND blocked from execution. Different from authorization (per-request) and from rule-based filtering (dynamic). + +## Quick example + +```typescript +@Tool({ + name: 'apple_notes_search', + description: 'Search Apple Notes', + inputSchema, + outputSchema, + availableWhen: { os: ['darwin'] }, // macOS-only +}) +class AppleNotesSearchTool extends ToolContext { + /* … */ +} +``` + +On Linux / Windows servers, this tool simply doesn't exist — it's not in `tools/list`, and calling it returns `EntryUnavailableError`. + +## Axes + +| Axis | Values | Source | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `os` | `'darwin'`, `'linux'`, `'win32'` | `process.platform` (since #417 — was previously `platform`) | +| `runtime` | `'node'`, `'browser'`, `'edge'`, `'bun'`, `'deno'` | Detected at boot | +| `deployment` | `'serverless'`, `'standalone'`, `'distributed'`, `'browser'` | Detected from `frontmcp.config` / env | +| `provider` | `'bare'`, `'docker'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'netlify'`, `'azure'`, `'gcp'`, `'fly'`, `'render'`, `'railway'` | Auto-detected; override with `FRONTMCP_PROVIDER=` | +| `target` | `'cli'`, `'node'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'browser'`, `'sdk'`, `'mcpb'`, `'distributed'` | Set by `frontmcp build --target `; `'unknown'` in dev | +| `surface` | `'mcp'`, `'cli'`, `'agent'`, `'job'`, `'http-trigger'` | Per-call axis — which entry point is invoking the tool | +| `env` | `'production'`, `'development'`, `'test'` | `process.env.NODE_ENV` | + +## Semantics + +- **Multiple axes** are AND-ed. `{ os: ['darwin'], env: ['production'] }` means macOS in production. +- **Multiple values within an axis** are OR-ed. `os: ['darwin', 'linux']` means macOS OR Linux (not Windows). +- **Omitted axes** are wildcard. No `env` field → matches every env. + +```typescript +@Tool({ + name: 'deploy_service', + // Node.js production-only: + availableWhen: { runtime: ['node'], env: ['production'] }, +}) +``` + +## Error shape on mismatch + +When the constraint fails at call time, FrontMCP throws `EntryUnavailableError` (`-32099`). Its `data` carries `missingAxes: string[]` (since #417) so clients can surface a specific reason without parsing prose: + +```json +{ + "code": -32099, + "message": "Tool 'deploy_service' is not available in this environment.", + "data": { + "missingAxes": ["env"], + "expected": { "env": ["production"] }, + "actual": { "env": "development" } + } +} +``` + +## Imperative checks + +You can also check the platform inside `execute()` for branches that aren't hard constraints: + +```typescript +async execute(input: Input) { + if (this.isPlatform('darwin')) { + return this.useNativeNotes(input); + } + return this.useCrossPlatformFallback(input); +} +``` + +| Method | Returns | +| --------------------- | ---------------------------------------------------------------------------------- | +| `this.isPlatform(os)` | `boolean` (alias preserved: `'platform'` works as a deprecated synonym for `'os'`) | +| `this.isRuntime(rt)` | `boolean` | +| `this.isEnv(env)` | `boolean` | + +These are fine for ergonomic branching. For tools that **shouldn't exist at all** on certain platforms, prefer the declarative `availableWhen` — it removes the tool from `tools/list` (clients won't even propose it). + +## `surface` — the per-call axis + +`surface` is the only axis that varies per-call. Use it when a tool should be reachable by some entry points but not others: + +```typescript +@Tool({ + name: 'rotate_secrets', + availableWhen: { surface: ['agent', 'job'] }, // not callable from MCP clients or CLI directly +}) +``` + +This is the safest way to expose internal-only tools that you want an agent / job to call but don't want a user to invoke from a chat UI. + +## See also + +- [`21-tool-with-availability-constraints`](../examples/21-tool-with-availability-constraints.md) +- [`decorator-options.md`](./decorator-options.md) diff --git a/libs/skills/catalog/create-tool/references/decorator-options.md b/libs/skills/catalog/create-tool/references/decorator-options.md new file mode 100644 index 000000000..644f5b73d --- /dev/null +++ b/libs/skills/catalog/create-tool/references/decorator-options.md @@ -0,0 +1,92 @@ +--- +name: decorator-options +description: Every field on @Tool({...}) — what it does, default, when to set it. +--- + +# `@Tool({...})` options + +Full surface of the `@Tool` decorator. Mandatory fields are bolded. + +| Field | Type | Default | When to set | +| ------------------- | ------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`name`** | `string` (snake_case) | — | Always. MCP protocol convention. `get_weather`, not `getWeather`. | +| `description` | `string` | — | Almost always. Shows in `tools/list` and helps clients choose the right tool. | +| **`inputSchema`** | Zod raw shape | — | Always. `{ field: z.string() }` — never wrapped in `z.object`. See [`input-schema.md`](./input-schema.md). | +| `outputSchema` | Zod raw shape / Zod schema / primitive literal / media literal / array | — | **Always** — prevents data leaks and enables CodeCall chaining. See [`output-schema.md`](./output-schema.md). | +| `annotations` | `{ title?, readOnlyHint?, destructiveHint?, idempotentHint?, openWorldHint? }` | — | When the tool has notable behavioral semantics clients should be hinted about. See [`annotations.md`](./annotations.md). | +| `rateLimit` | `{ maxRequests, windowMs }` | — | Expensive / external-dependent / abuse-prone tools. See [`throttling.md`](./throttling.md). | +| `concurrency` | `{ maxConcurrent }` | — | Tools that hold a scarce resource (DB connection, GPU). See [`throttling.md`](./throttling.md). | +| `timeout` | `{ executeMs }` | — | Any tool that could legitimately hang. See [`throttling.md`](./throttling.md). | +| `authProviders` | `string[]` \| `Array<{ name, scopes?, required?, alias? }>` | — | Tool requires user OAuth credentials. See [`auth-providers.md`](./auth-providers.md). | +| `availableWhen` | `{ os?, runtime?, deployment?, provider?, target?, surface?, env? }` | — | Hard registry-level constraint — tool is filtered out of `tools/list` and execution when context doesn't match. See [`availability.md`](./availability.md). | +| `examples` | `Array<{ description, input, output? }>` | — | Discovery / docs surfacing. The client may show these to the user. | +| `ui` | `ToolUIConfig` | — | Tool result should render as a widget in the host UI. See [`ui-widgets.md`](./ui-widgets.md). | +| `hideFromDiscovery` | `boolean` | `false` | Hide from `tools/list` but still callable. Use for internal / agent-only tools. | + +> The decorator is type-safe: `outputSchema` flows back into `ToolContext.execute()`'s return type without needing explicit generics on the class. See [`derived-types.md`](./derived-types.md). + +## Mandatory fields + +- `name` — must be unique within the server, `snake_case`. Used as the lookup key for `tools/call`. +- `inputSchema` — even an empty-input tool declares `inputSchema: {}`. The framework wraps it in `z.object(...)` internally and validates every call. + +## Almost-always-set fields + +- `description` — without it the tool is anonymous in `tools/list`. AI clients pick tools based on description text. +- `outputSchema` — see [`rules/always-define-output-schema.md`](../rules/always-define-output-schema.md). + +## Field interactions + +- **`rateLimit` + `concurrency`** — independent. Rate-limit caps invocations over time; concurrency caps simultaneous in-flight. A "1 req/s with max 2 concurrent" tool is fine: bursts can run two at once, then back off. +- **`timeout` + `rateLimit`** — orthogonal. Timeout wraps a single call; rate-limit wraps the rate of calls. +- **`authProviders` + `ui.widgetAccessible`** — the widget bridge respects the tool's auth requirements. A widget that calls back to a tool requiring `authProviders: ['github']` will fail in the bridge if no GitHub session exists. +- **`availableWhen` + `hideFromDiscovery`** — `availableWhen` is a hard constraint (filtered AND blocked); `hideFromDiscovery` is a soft hide (filtered but still callable). +- **`ui.servingMode === 'static'` + `availableWhen`** — static widgets pre-compile at startup. If a tool is filtered out by `availableWhen`, its static widget isn't compiled either. + +## Forbidden combinations + +- `inputSchema: z.object(...)` at the top level — see [`rules/input-schema-is-raw-shape.md`](../rules/input-schema-is-raw-shape.md). +- `extends ToolContext` — explicit generics on the class. See [`rules/no-toolcontext-generics.md`](../rules/no-toolcontext-generics.md). +- Mixing function-style `tool({...})(handler)` with class-style `@Tool` + `extends ToolContext` for the same tool. Pick one. + +## Minimal vs production + +```typescript +// Minimal — fine for a prototype tool +@Tool({ + name: 'ping', + description: 'Liveness check', + inputSchema: {}, + outputSchema: 'string', +}) +class PingTool extends ToolContext { + execute(): string { + return 'pong'; + } +} +``` + +```typescript +// Production — full surface for a real action +@Tool({ + name: 'create_issue', + description: 'Create a GitHub issue in the active repo', + inputSchema, + outputSchema, + annotations: { title: 'Create issue', destructiveHint: false, idempotentHint: false, openWorldHint: true }, + rateLimit: { maxRequests: 30, windowMs: 60_000 }, + concurrency: { maxConcurrent: 5 }, + timeout: { executeMs: 30_000 }, + authProviders: [{ name: 'github', required: true, scopes: ['repo'] }], + availableWhen: { surface: ['mcp', 'agent'] }, + examples: [{ description: 'File a bug', input: { title: 'X is broken', body: '…' } }], +}) +class CreateIssueTool extends ToolContext { + /* … */ +} +``` + +## See also + +- [`rules/`](../rules/) — short DO/DON'T constraints per field +- [`execution-context.md`](./execution-context.md) — what `ToolContext` provides at runtime diff --git a/libs/skills/catalog/create-tool/references/derived-types.md b/libs/skills/catalog/create-tool/references/derived-types.md new file mode 100644 index 000000000..8e71a1d4d --- /dev/null +++ b/libs/skills/catalog/create-tool/references/derived-types.md @@ -0,0 +1,102 @@ +--- +name: derived-types +description: Derive execute() parameter and return types from the schemas via ToolInputOf / ToolOutputOf. +--- + +# Derived `execute()` types + +The schema is the single source of truth. Hand-typing `execute(input: { name: string })` next to a schema declaring `name: z.string()` is a second declaration of the same shape — change the schema without touching the annotation and TypeScript happily compiles while runtime validation silently rejects. + +Derive types from the schemas with `ToolInputOf<>` / `ToolOutputOf<>`. The compiler catches divergence at build time. + +## Pattern + +```typescript +// src/apps/main/tools/greet-user.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { + name: z.string().describe('The name to greet'), +}; + +export const outputSchema = { + greeting: z.string(), +}; + +export type GreetUserInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GreetUserOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/greet-user.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { inputSchema, outputSchema, type GreetUserInput, type GreetUserOutput } from './greet-user.schema'; + +@Tool({ + name: 'greet_user', + description: 'Greet a user by name', + inputSchema, + outputSchema, +}) +export class GreetUserTool extends ToolContext { + async execute(input: GreetUserInput): Promise { + return { greeting: `Hello, ${input.name}!` }; + } +} +``` + +## Two equivalent forms + +```typescript +// Form 1 — SDK helpers (preferred — survives any future shape changes to ToolContext) +type GreetUserInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +type GreetUserOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; + +// Form 2 — raw zod (terser if you don't mind a direct z dependency) +type GreetUserInput = z.infer>; +type GreetUserOutput = z.infer>; +``` + +Both produce identical types. Pick whichever fits the surrounding code. Form 1 is recommended because `ToolInputOf` / `ToolOutputOf` track any future shape changes to `ToolContext` (e.g., if metadata wrapping changes). + +## What to hoist, what to leave inline + +Hoist **only the schemas** to `.schema.ts`. The decorator config (`name`, `description`, `annotations`, `rateLimit`, `authProviders`, …) stays inside `@Tool({…})` where it belongs. + +```typescript +// ✅ schemas only — re-importable by specs, sibling tools, generated clients +export const inputSchema = { … }; +export const outputSchema = { … }; + +// ❌ don't hoist the @Tool config — it's tool-specific metadata, not a shape contract +export const toolConfig = { name: 'greet_user', description: '…', inputSchema, outputSchema }; +``` + +## Don't add generics to ToolContext + +```typescript +// ❌ ToolContext — redundant; @Tool decorator already infers them +class GreetUserTool extends ToolContext { … } + +// ✅ Plain ToolContext — @Tool's inference flows in automatically +class GreetUserTool extends ToolContext { … } +``` + +See [`rules/no-toolcontext-generics.md`](../rules/no-toolcontext-generics.md). + +## Why derive? + +| Without derived types | With derived types | +| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Change `inputSchema` → execute()'s `input:` annotation silently goes stale → runtime validation rejects calls that the compiler accepted | Change `inputSchema` → execute() signature follows → compiler catches drift at the call site | +| Specs and helpers hand-type their own shapes (third source of truth) | Specs import `GreetUserInput` from the schema file (one source of truth) | +| Generated clients drift from server contract | Generated clients import the same exported types | + +## See also + +- [`input-schema.md`](./input-schema.md) +- [`output-schema.md`](./output-schema.md) +- [`file-layout.md`](./file-layout.md) +- [`rules/derive-execute-types.md`](../rules/derive-execute-types.md) +- [`rules/no-toolcontext-generics.md`](../rules/no-toolcontext-generics.md) diff --git a/libs/skills/catalog/create-tool/references/elicitation.md b/libs/skills/catalog/create-tool/references/elicitation.md new file mode 100644 index 000000000..23b3f3536 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/elicitation.md @@ -0,0 +1,122 @@ +--- +name: elicitation +description: this.elicit — request interactive input mid-execution. Server enable + accept/decline/cancel flow. +--- + +# Elicitation + +`this.elicit(message, schema)` lets a tool ask for additional input mid-execution. The MCP client renders a UI (form / prompt), the user fills it in, and the response flows back to your `execute()` body. + +## Prerequisite — enable at server level + +```typescript +@FrontMcp({ + info: { name: '…', version: '1.0.0' }, + apps: [MainApp], + elicitation: { enabled: true }, +}) +``` + +Without `elicitation.enabled: true`, every `this.elicit(...)` call throws `ElicitationDisabledError` at runtime. There is no compile-time warning — the error only fires when the tool actually runs. + +For production, configure a Redis-backed elicitation store via `elicitation.store: { provider: 'redis', … }` (the default in-memory store loses pending elicitations on restart). + +## Quick example + +```typescript +@Tool({ + name: 'confirm_delete', + description: 'Delete a resource after explicit user confirmation', + inputSchema: { resourceId: z.string() }, + outputSchema: { deleted: z.boolean() }, + annotations: { destructiveHint: true, idempotentHint: true }, +}) +class ConfirmDeleteTool extends ToolContext { + async execute(input: { resourceId: string }) { + const result = await this.elicit('Permanently delete this resource? This cannot be undone.', { + confirm: z.boolean().describe('Type true to confirm'), + reason: z.string().optional().describe('Optional reason for the audit log'), + }); + + if (result.action !== 'accept' || !result.data.confirm) { + return { deleted: false }; + } + + await this.get(ResourceService).delete(input.resourceId, { reason: result.data.reason }); + return { deleted: true }; + } +} +``` + +## Return shape + +`this.elicit` returns: + +```typescript +type ElicitationResult = + | { action: 'accept'; data: T } // user filled the form and submitted + | { action: 'decline' } // user clicked decline / no + | { action: 'cancel' }; // user closed the prompt without responding +``` + +Always check `result.action` before reading `result.data` — `data` only exists on `accept`. + +## Multiple fields, optional fields, defaults + +```typescript +const result = await this.elicit('Choose deployment options', { + environment: z.enum(['staging', 'production']).default('staging'), + rollback: z.boolean().default(false).describe('Roll back on first health-check failure'), + notifyChannel: z.string().optional().describe('Slack channel for notifications (e.g. #ops)'), +}); + +if (result.action === 'accept') { + // result.data: { environment: 'staging' | 'production'; rollback: boolean; notifyChannel?: string } +} +``` + +## What clients render + +The client's UI varies — MCP Inspector shows a JSON form, Claude / ChatGPT may render a structured input dialog. Field types translate roughly: + +| Zod | Typical UI | +| --------------------------------- | ----------------------------------------------------- | +| `z.string()` | Single-line text input | +| `z.string().describe('long…')` | Textarea if `describe` includes the word "multi-line" | +| `z.number()` / `z.number().int()` | Number input with stepper | +| `z.boolean()` | Checkbox | +| `z.enum([…])` | Select / radio group | +| `z.string().email()` | Email input | +| `z.string().url()` | URL input | +| `z.string().datetime()` | Date-time picker | + +Treat the UI as best-effort — don't depend on a particular widget. The contract is the Zod schema; the rendering is the host's job. + +## Early return on decline / cancel + +Early returns must still match `outputSchema`: + +```typescript +async execute(input: { resourceId: string }) { + const result = await this.elicit('Delete?', { confirm: z.boolean() }); + if (result.action !== 'accept') { + // Must return a value matching outputSchema — not a raw error string + return { deleted: false }; + } + // … +} +``` + +If declining should propagate as an error to the client (rather than a normal output), use `this.fail` instead: + +```typescript +if (result.action === 'decline') { + this.fail(new PublicMcpError('User declined the destructive action.')); +} +``` + +## See also + +- [`19-tool-with-elicitation`](../examples/19-tool-with-elicitation.md) +- [`execution-context.md`](./execution-context.md) +- `config` skill — `elicitation.store` (Redis vs memory) configuration diff --git a/libs/skills/catalog/create-tool/references/error-handling.md b/libs/skills/catalog/create-tool/references/error-handling.md new file mode 100644 index 000000000..beef10455 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/error-handling.md @@ -0,0 +1,128 @@ +--- +name: error-handling +description: this.fail, MCP error classes, error flow — when to throw vs fail. +--- + +# Error handling + +## The rule + +**Don't `try/catch` around `execute()`** ([rule](../rules/no-try-catch-around-execute.md)). The framework's flow catches exceptions, formats them into proper JSON-RPC errors, runs error hooks, and emits notifications. Wrapping the body defeats all of that. + +```typescript +// ❌ swallows the error, breaks the framework's flow +async execute(input: Input) { + try { + const result = await someOperation(); + return result; + } catch (err) { + this.fail(err instanceof Error ? err : new Error(String(err))); + } +} + +// ✅ let it propagate +async execute(input: Input) { + return await someOperation(); +} +``` + +## Business-logic errors → `this.fail` + +For errors the user / agent should see (not-found, permission-denied, invalid-input, conflict, etc.), use `this.fail(new SomeMcpError(…))`: + +```typescript +async execute(input: { id: string }) { + const record = await this.findRecord(input.id); + if (!record) { + this.fail(new ResourceNotFoundError(`record:${input.id}`)); + // ↑ never returns; flow aborts here + } + // … keep going with `record` (typed as non-null because fail() doesn't return) +} +``` + +`this.fail` throws internally and never returns — TypeScript knows it's a `never`-returning method. + +## Infrastructure errors → propagate + +For errors the framework should handle uniformly (network failure, DB unavailable, timeout), just let them throw. The framework wraps them in an `InternalMcpError` with the message redacted before reaching the client, and logs the original for ops. + +## MCP error classes + +All from `@frontmcp/sdk`. The two roots: `PublicMcpError` (message reaches the client verbatim) and `InternalMcpError` (message is redacted; full details go to logs). + +| Class | Error code | HTTP | When | +| ----------------------- | ---------------------------------------- | ---- | ----------------------------------------------------------------- | +| `PublicMcpError` | — | — | Base for public errors. Subclass for domain-specific cases. | +| `InternalMcpError` | — | — | Base for redacted infra errors | +| `ResourceNotFoundError` | `'RESOURCE_NOT_FOUND'` (-32002 JSON-RPC) | 404 | A specific resource doesn't exist | +| `ToolNotFoundError` | `'TOOL_NOT_FOUND'` | 404 | Tool name not registered | +| `InvalidInputError` | `'INVALID_INPUT'` | 400 | Cross-field / business-rule input invalid (Zod handles per-field) | +| `InvalidMethodError` | `'INVALID_METHOD'` | 400 | Wrong protocol method called | +| `UnauthorizedError` | `'UNAUTHORIZED'` (-32001 JSON-RPC) | 401 | Missing credentials | +| `EntryUnavailableError` | `'FORBIDDEN'` (-32003 JSON-RPC) | 403 | `availableWhen` mismatch at call time | +| `RateLimitError` | `'RATE_LIMIT_EXCEEDED'` | 429 | Rate-limit fired | +| `QuotaExceededError` | `'QUOTA_EXCEEDED'` | 429 | Quota-style limit fired | +| `PayloadTooLargeError` | `'PAYLOAD_TOO_LARGE'` | 413 | Body limit exceeded | + +`MCP_ERROR_CODES` is the JSON-RPC numeric-code constant map (`UNAUTHORIZED: -32001`, `RESOURCE_NOT_FOUND: -32002`, `FORBIDDEN: -32003`, `INVALID_PARAMS: -32602`, `INTERNAL_ERROR: -32603`, etc.). The classes use string error codes; the numeric codes appear on the JSON-RPC wire response. + +```typescript +import { InvalidInputError, MCP_ERROR_CODES, PublicMcpError, ResourceNotFoundError } from '@frontmcp/sdk'; + +this.fail(new ResourceNotFoundError(`record:${input.id}`)); +this.fail(new InvalidInputError('start must be before end')); +``` + +## Custom error classes + +Subclass `PublicMcpError` for domain-specific errors: + +```typescript +class QuotaExceededError extends PublicMcpError { + readonly mcpErrorCode = -32100; // any custom code outside the reserved JSON-RPC ranges + + constructor(public readonly remaining: number) { + super(`Quota exceeded — ${remaining} requests left in window`); + } + + toJsonRpcError() { + return { + code: this.mcpErrorCode, + message: this.getPublicMessage(), + data: { remaining: this.remaining }, + }; + } +} + +// usage +this.fail(new QuotaExceededError(0)); +``` + +The `data` payload lets you surface structured info to the client (rate-limit remaining, validation field errors, etc.) without leaking internals. + +## `PublicMcpError` vs raw `Error` + +| Throw | Client sees | +| -------------------------------------- | ----------------------------------------------------------------------- | +| `new PublicMcpError('Quota exceeded')` | `{ code: -32603, message: 'Quota exceeded' }` | +| `new Error('Quota exceeded')` | `{ code: -32603, message: 'Internal error' }` (the message is REDACTED) | + +Raw `Error`s have their messages **redacted** before reaching the client — the framework treats them as potentially-sensitive infrastructure errors. For anything the client should read, use `PublicMcpError` or a subclass. + +## Non-null assertions are forbidden + +```typescript +// ❌ masks failures +const rec = this.defs.get(token)!; + +// ✅ proper handling +const rec = this.defs.get(token); +if (!rec) this.fail(new ResourceNotFoundError(`def:${token}`)); +``` + +## See also + +- [`rules/no-try-catch-around-execute.md`](../rules/no-try-catch-around-execute.md) +- [`rules/use-this-fail-for-business-errors.md`](../rules/use-this-fail-for-business-errors.md) +- [`execution-context.md`](./execution-context.md) diff --git a/libs/skills/catalog/create-tool/references/execution-context.md b/libs/skills/catalog/create-tool/references/execution-context.md new file mode 100644 index 000000000..7941d369b --- /dev/null +++ b/libs/skills/catalog/create-tool/references/execution-context.md @@ -0,0 +1,145 @@ +--- +name: execution-context +description: What ToolContext provides at runtime — this.get, this.fetch, this.notify, this.context. +--- + +# `ToolContext` runtime API + +`ToolContext` extends `ExecutionContextBase`. Inside `execute()` you have access to: + +## Methods + +| Method | Purpose | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `execute(input: In): Promise` | The method you implement | +| `this.get(token)` | Resolve a DI dependency. Throws `DependencyNotFoundError` if not registered. | +| `this.tryGet(token)` | Resolve a DI dependency. Returns `undefined` if not registered. | +| `this.fail(err)` | Abort execution, trigger the error flow. **Never returns.** Use for business-logic errors. | +| `this.respond(value)` | Early-return with a value. Validates against `outputSchema`. **Never returns** (throws `FlowControl.respond`). | +| `this.mark(stage)` | Set the active execution stage for debugging / tracing | +| `this.fetch(input, init?)` | HTTP fetch with context propagation (trace headers, etc.) | +| `this.notify(message, level?)` | Send a log-level notification to the client | +| `this.progress(progress, total?, message?)` | Send a progress notification. Returns `Promise` (false when no progress token in request) | +| `this.elicit(message, schema)` | Request interactive input from the user mid-execution. See [`elicitation.md`](./elicitation.md) | +| `this.isPlatform(os)` / `this.isRuntime(rt)` / `this.isEnv(env)` | Imperative platform checks (declarative form is `availableWhen` — see [`availability.md`](./availability.md)) | + +## Properties + +| Property | Type | Description | +| --------------- | ------------------ | ---------------------------------------------------------------- | +| `this.input` | `In` | The validated input object (same value as the `input` parameter) | +| `this.output` | `Out \| undefined` | The output value (available after `execute()` returns) | +| `this.metadata` | tool metadata | Frozen view of the `@Tool({...})` config | +| `this.scope` | scope instance | The current scope — DI lookups, child scopes | +| `this.context` | `FrontMcpContext` | Per-request context (see below) | + +## `this.context` (FrontMcpContext) + +| Property | Type | Description | +| -------------- | ------------------- | ---------------------------------------------------------------------- | +| `requestId` | `string` | Unique ID for this request | +| `sessionId` | `string` | Session identifier (for stateful transports) | +| `scopeId` | `string` | Scope identifier (for multi-app servers) | +| `authInfo` | `Partial` | Authentication info — `userId`, `email`, `scopes`, `tokens`, … | +| `traceContext` | `TraceContext` | Distributed-tracing context (propagated to `this.fetch` automatically) | +| `timestamp` | `number` | Request start timestamp | +| `metadata` | `RequestMetadata` | Request headers, client IP, MCP client name/version | + +## DI: `this.get` vs `this.tryGet` + +```typescript +import { Token } from '@frontmcp/di'; + +interface UserService { findById(id: string): Promise; } +const USER_SERVICE: Token = Symbol('UserService'); + +async execute(input: { userId: string }) { + // Throws DependencyNotFoundError if USER_SERVICE isn't registered in scope + const users = this.get(USER_SERVICE); + + // Returns undefined if not registered — for optional deps + const cache = this.tryGet(CACHE); + if (cache) { + const cached = await cache.get(input.userId); + if (cached) return cached; + } + + const user = await users.findById(input.userId); + if (!user) this.fail(new ResourceNotFoundError(`user:${input.userId}`)); + return user; +} +``` + +Use `this.get` (throws) when the tool genuinely requires the dependency. Use `this.tryGet` (returns undefined) when the tool degrades gracefully without it (e.g., optional cache, optional metrics emitter). + +## HTTP: `this.fetch` + +`this.fetch` is a thin wrapper around the standard `fetch` that propagates the request's `traceContext` so downstream services can stitch the call into the same trace. + +```typescript +async execute(input: { url: string }) { + const response = await this.fetch(input.url); + if (!response.ok) { + this.fail(new InternalError(`upstream returned ${response.status}`)); + } + return response.json(); +} +``` + +It accepts the same arguments as standard `fetch`: + +```typescript +this.fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5_000), +}); +``` + +> Don't `try/catch` around the fetch and swallow errors — let infrastructure errors propagate to the framework. Only use `this.fail` for **business-logic** errors. See [`error-handling.md`](./error-handling.md). + +## Notifications: `this.notify` + `this.progress` + +```typescript +async execute(input: { items: string[] }) { + this.mark('validation'); + // … + this.mark('processing'); + for (let i = 0; i < input.items.length; i++) { + await this.progress(i + 1, input.items.length, `Processing ${input.items[i]}`); + await this.processItem(input.items[i]); + } + await this.notify(`Processed ${input.items.length} items`, 'info'); + this.mark('complete'); + return { processed: input.items.length }; +} +``` + +- `this.notify(msg, level?)` — sends `notifications/message` to the client (`debug` / `info` / `warning` / `error`). Always-best-effort. +- `this.progress(n, total?, msg?)` — sends `notifications/progress` IF the request had a progress token. Returns `false` when no token was provided (so the call costs almost nothing if nobody's listening). +- `this.mark(stage)` — server-side breadcrumb, surfaced in logs / metrics / traces. No client notification. + +See [`18-tool-with-progress-and-notify`](../examples/18-tool-with-progress-and-notify.md). + +## Early return: `this.respond` + +Both `return value` and `this.respond(value)` validate against `outputSchema`. `this.respond` throws an internal `FlowControl.respond` and never returns — useful for early-exit branches: + +```typescript +async execute(input: Input) { + const cached = await this.tryGet(CACHE)?.get(input.key); + if (cached) this.respond(cached); // never returns; just for early exit + + const result = await this.compute(input); + return result; +} +``` + +> `this.respond` doesn't bypass `outputSchema` — its argument is validated like a normal return value. + +## See also + +- [`error-handling.md`](./error-handling.md) +- [`auth-providers.md`](./auth-providers.md) — `this.context.authInfo` and `this.authProviders` +- [`elicitation.md`](./elicitation.md) — `this.elicit` diff --git a/libs/skills/catalog/create-tool/references/file-layout.md b/libs/skills/catalog/create-tool/references/file-layout.md new file mode 100644 index 000000000..32ed29df6 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/file-layout.md @@ -0,0 +1,96 @@ +--- +name: file-layout +description: Flat sibling vs folder-per-tool layouts. .schema.ts / .tool.ts / .tool.spec.ts. +--- + +# File layout for tools + +Two endorsed layouts. Pick based on tool count per app and whether each tool has local helpers / fixtures / error types. + +## Flat siblings (≤3 tools per app, or each tool fits in one screen) + +```text +src/apps/main/tools/ +├── get-weather.tool.ts # @Tool class, execute() +├── get-weather.schema.ts # input/output schemas + derived types +├── get-weather.tool.spec.ts # unit tests +├── greet-user.tool.ts +├── greet-user.schema.ts +└── greet-user.tool.spec.ts +``` + +## Folder-per-tool (>3 tools per app, or tool has helpers / fixtures / errors) + +```text +src/apps/main/tools/ +├── get-weather/ +│ ├── get-weather.tool.ts # @Tool class, execute() +│ ├── get-weather.schema.ts # input/output schemas + derived types +│ ├── get-weather.tool.spec.ts # unit tests +│ ├── get-weather.errors.ts # GetWeatherUnavailableError etc. +│ ├── get-weather.fixtures.ts # test fixtures, shared with the spec +│ ├── helpers.ts # tool-local utility functions +│ ├── index.ts # barrel re-export +│ └── get-weather.widget.tsx # optional UI widget (if ui: { file: … }) +└── greet-user/ + └── … +``` + +`index.ts` for the folder layout: + +```typescript +// src/apps/main/tools/get-weather/index.ts +export { GetWeatherTool } from './get-weather.tool'; +export { + inputSchema as getWeatherInputSchema, + outputSchema as getWeatherOutputSchema, + type GetWeatherInput, + type GetWeatherOutput, +} from './get-weather.schema'; +``` + +## File-name conventions + +| File | Purpose | +| --------------------- | -------------------------------------------------------------------------------------------- | +| `.tool.ts` | The `@Tool`-decorated class (or `tool({...})(handler)` value) | +| `.schema.ts` | `inputSchema`, `outputSchema`, and the derived `Input` / `Output` types | +| `.tool.spec.ts` | Unit test (jest). NOT `.test.ts` — see [CLAUDE.md test-file-naming rule](../../../CLAUDE.md) | +| `.widget.tsx` | Optional UI widget — `.widget.tsx` is excluded from server typecheck (#445 fix) | +| `.errors.ts` | Optional — custom `PublicMcpError` subclasses for this tool | +| `.fixtures.ts` | Optional — test fixtures the spec imports | + +## Why split schema and tool? + +```typescript +// ✅ Schema in its own file — re-importable by: +// - the tool itself +// - the .tool.spec.ts file +// - sibling tools (e.g. the create-X tool may share a schema field with the update-X tool) +// - generated clients +// - elicitation flows that reuse the same Zod field + +// src/apps/main/tools/get-weather.schema.ts +export const inputSchema = { city: z.string() }; +export const outputSchema = { temperatureF: z.number() }; +export type GetWeatherInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GetWeatherOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +If you hoisted the entire `@Tool({...})` config, consumers would drag the `@Tool` decorator transitively. Schemas alone are inert. + +## Naming + +- File names: `kebab-case` — `get-weather.tool.ts`. +- Class names: `PascalCase` — `GetWeatherTool`. The `Tool` suffix is conventional, not required. +- Tool `name:` field: `snake_case` — `get_weather`. MCP protocol convention. ([rule](../rules/snake-case-tool-names.md)) + +## Widget files + +`.tsx` / `.jsx` widget files use the `*.widget.tsx` naming convention. The scaffolded `tsconfig.json` excludes `**/*.widget.tsx` from the server typecheck (#445 fix) — widgets are bundled separately by `@frontmcp/uipack` (esbuild) at render time, with React loaded externally. If you want IDE typecheck for widget sources, add a sibling `tsconfig.widget.json` with `jsx: 'react-jsx'` and `include: ['src/**/*.widget.tsx']`. + +## See also + +- [`derived-types.md`](./derived-types.md) — why schemas hoist +- [`testing.md`](./testing.md) — `.tool.spec.ts` patterns +- [`ui-widgets.md`](./ui-widgets.md) — widget file conventions diff --git a/libs/skills/catalog/create-tool/references/function-style-builder.md b/libs/skills/catalog/create-tool/references/function-style-builder.md new file mode 100644 index 000000000..6e68dd7b3 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/function-style-builder.md @@ -0,0 +1,112 @@ +--- +name: function-style-builder +description: tool({...})(handler) — when to pick over a class, register, ctx parameter. +--- + +# Function-style tools — `tool({...})(handler)` + +For simple tools that don't need DI, lifecycle hooks, or UI widgets, the `tool()` function builder is a one-liner alternative to a class. + +## Shape + +```typescript +import { tool, z } from '@frontmcp/sdk'; + +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) => input.a + input.b); +``` + +Register it the same way as a class tool: + +```typescript +@App({ name: 'main', tools: [AddNumbers] }) +class MainApp {} +``` + +## With `ctx` + +The handler receives `(input, ctx)`. `ctx` exposes the same methods a class tool would have via `this.*`: + +```typescript +const GetCurrentUser = tool({ + name: 'get_current_user', + description: 'Return the authenticated user', + inputSchema: {}, + outputSchema: { id: z.string(), email: z.string().email() }, +})(async (_input, ctx) => { + const userId = ctx.context.authInfo.userId; + if (!userId) ctx.fail(new PublicMcpError('No authenticated user')); + const users = ctx.get(USER_SERVICE); + return users.findById(userId); +}); +``` + +`ctx` provides: `get`, `tryGet`, `fail`, `respond`, `mark`, `fetch`, `notify`, `progress`, `context`, `input`, `metadata`, `scope`, `elicit`, `isPlatform`, `isRuntime`, `isEnv`. + +## When to pick which + +| Class (`@Tool` + `extends ToolContext`) | Function (`tool({...})(handler)`) | +| ------------------------------------------------- | -------------------------------------- | +| Needs DI (`this.get`) — most production tools | Pure-input math / formatting / parsing | +| Needs lifecycle / hooks | One-off conversions | +| Needs a `ui:` widget | No bridge / no widget | +| Wants a `.tool.spec.ts` with module-level helpers | Spec testing via simple closure | +| Needs to be extended / decorated | Standalone | + +**Default to class.** Pick function only for tools that are trivially short AND don't need DI. + +## Async vs sync + +The handler can be sync or async — both are fine: + +```typescript +tool({ … })((input) => input.a + input.b); // sync +tool({ … })(async (input, ctx) => { /* … */ }); // async +``` + +## All the decorator options work + +`rateLimit`, `concurrency`, `timeout`, `annotations`, `authProviders`, `availableWhen`, `examples`, `hideFromDiscovery` are all valid on the function builder: + +```typescript +const SendEmail = tool({ + name: 'send_email', + description: 'Send an email via SendGrid', + inputSchema: { to: z.string().email(), subject: z.string(), body: z.string() }, + outputSchema: { messageId: z.string() }, + rateLimit: { maxRequests: 100, windowMs: 60_000 }, + authProviders: ['sendgrid'], + annotations: { openWorldHint: true }, +})(async (input, ctx) => { + const headers = await ctx.authProviders.headers('sendgrid'); + // … +}); +``` + +The `ui:` block also works: + +```typescript +const ShowCard = tool({ + name: 'show_card', + inputSchema: { text: z.string() }, + outputSchema: { text: z.string() }, + ui: { + template: (ctx) => `
${ctx.helpers.escapeHtml(ctx.output.text)}
`, + }, +})((input) => ({ text: input.text })); +``` + +…but at that point, you usually want a class for the file layout and `.tool.spec.ts` ergonomics. + +## See also + +- [`02-basic-function-tool`](../examples/02-basic-function-tool.md) +- [`execution-context.md`](./execution-context.md) — same surface available on `ctx` +- [`registration.md`](./registration.md) diff --git a/libs/skills/catalog/create-tool/references/input-schema.md b/libs/skills/catalog/create-tool/references/input-schema.md new file mode 100644 index 000000000..9cd82ede1 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/input-schema.md @@ -0,0 +1,141 @@ +--- +name: input-schema +description: Define the tool's input contract — raw Zod shapes, refinements, defaults, optional fields. +--- + +# `inputSchema` reference + +The `inputSchema` field on `@Tool({...})` accepts a **Zod raw shape** — a plain object mapping field names to Zod types. The framework wraps it in `z.object(...)` internally and validates every call. + +## The raw-shape rule + +```typescript +// ✅ Raw shape +@Tool({ + name: 'search', + inputSchema: { + query: z.string().min(1), + limit: z.number().int().min(1).max(100).default(10), + }, +}) + +// ❌ z.object() at the top level +@Tool({ + name: 'search', + inputSchema: z.object({ + query: z.string(), + }), +}) +``` + +See [`rules/input-schema-is-raw-shape.md`](../rules/input-schema-is-raw-shape.md). The wrapper is the framework's job — wrapping it yourself confuses the type inference and breaks `ToolInputOf<>`. + +## Field types + +| Want | Zod | +| ------------------- | ------------------------------------------------------------------------ | +| Required string | `z.string()` | +| Optional string | `z.string().optional()` | +| String with default | `z.string().default('hello')` | +| Bounded number | `z.number().int().min(1).max(100)` | +| Number with default | `z.number().default(10)` | +| Enum | `z.enum(['a', 'b', 'c'])` | +| Array | `z.array(z.string())` | +| Nested object | `z.object({ city: z.string() })` (Zod object **inside** a field is fine) | +| Discriminated union | `z.discriminatedUnion('kind', […])` | +| Date | `z.string().datetime()` (ISO 8601) or `z.date()` | +| URL | `z.string().url()` | +| Email | `z.string().email()` | +| Refined | `z.string().refine((v) => v.length % 2 === 0, 'must be even length')` | +| Branded | `z.string().brand<'UserId'>()` | + +> Only the **top-level** `inputSchema` must be a raw shape. Nested objects use `z.object({...})` normally. + +## Descriptions + +Every field should carry `.describe('…')` — it's shown to AI clients in `tools/list`, helping them choose argument values: + +```typescript +inputSchema: { + city: z.string().describe('City name, e.g. "Seattle" or "Tel Aviv"'), + units: z.enum(['celsius', 'fahrenheit']).default('celsius').describe('Temperature units'), +} +``` + +## Optional vs default + +```typescript +inputSchema: { + // Required — caller must provide + query: z.string(), + + // Optional — execute() sees `string | undefined` + category: z.string().optional(), + + // Optional with default — execute() sees `string` (the default fills in) + limit: z.number().default(10), +} +``` + +## Refinements + +For cross-field validation, wrap individual fields: + +```typescript +inputSchema: { + start: z.string().datetime(), + end: z.string().datetime(), + // single-field refinements: + email: z.string().email().refine((v) => v.endsWith('@example.com'), 'must be a corporate email'), +} +``` + +For cross-field refinements (`start < end`), keep `inputSchema` simple and validate in `execute()`: + +```typescript +async execute(input: { start: string; end: string }) { + if (input.start >= input.end) { + this.fail(new InvalidInputError('start must be before end')); + } + // … +} +``` + +(Or use a custom `.transform` / `.refine` on a wrapped `z.object({...})` _outside_ `inputSchema` and pass `.shape` — but that's usually overkill.) + +## Empty input + +A no-input tool declares an empty shape: + +```typescript +@Tool({ + name: 'ping', + inputSchema: {}, + outputSchema: 'string', +}) +class PingTool extends ToolContext { + execute(): string { + return 'pong'; + } +} +``` + +## Where the validated input lives + +After validation, `execute(input)` receives the typed/defaulted input. It's also available via `this.input` if you'd rather: + +```typescript +async execute(_input: SearchInput) { + // these are equivalent: + const fromArg = _input.query; + const fromCtx = this.input.query; +} +``` + +Prefer the parameter — it's typed without needing the `ToolInputOf<>` annotation on `this.input`. + +## See also + +- [`output-schema.md`](./output-schema.md) +- [`derived-types.md`](./derived-types.md) +- [`rules/input-schema-is-raw-shape.md`](../rules/input-schema-is-raw-shape.md) diff --git a/libs/skills/catalog/create-tool/references/output-schema.md b/libs/skills/catalog/create-tool/references/output-schema.md new file mode 100644 index 000000000..bc9eb515a --- /dev/null +++ b/libs/skills/catalog/create-tool/references/output-schema.md @@ -0,0 +1,131 @@ +--- +name: output-schema +description: Define the tool's output contract — Zod shape, primitives, media, multi-content arrays. +--- + +# `outputSchema` reference + +`outputSchema` is **always required** ([rule](../rules/always-define-output-schema.md)). It declares what `execute()` returns and gives the framework permission to strip any fields you didn't declare — the safety net against accidental PII / token / debug-trace leaks. + +## Supported shapes + +| Shape | Use for | Returns | +| --------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| **Zod raw shape** | Structured JSON | `{ field: z.string(), count: z.number() }` | +| **Zod schema** | Complex types (unions, discriminated unions, arrays of objects, transforms) | `z.object({…})`, `z.array(…)`, `z.discriminatedUnion('kind', […])` | +| **Primitive literal** | Single value | `'string'`, `'number'`, `'boolean'`, `'date'` | +| **Media literal** | Binary / link content | `'image'`, `'audio'`, `'resource'`, `'resource_link'` | +| **Array of literals** | Multi-content response | `['string', 'image']` — text + image in one response | + +## Zod raw shape (most common) + +```typescript +const outputSchema = { + temperatureF: z.number(), + conditions: z.string(), + humidityPct: z.number().int().min(0).max(100), +}; + +@Tool({ name: 'get_weather', inputSchema, outputSchema }) +class GetWeatherTool extends ToolContext { + async execute(input: GetWeatherInput): Promise { + const data = await this.fetch(`https://api.weather.example/${input.city}`).then((r) => r.json()); + // Even if `data` contains { temperatureF, conditions, internalApiKey, debugTrace, … } + // only temperatureF / conditions / humidityPct flow through. + return data; + } +} +``` + +## Zod schema (full Zod) + +When the output is a union, discriminated union, or array of objects: + +```typescript +const outputSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('user'), id: z.string(), name: z.string() }), + z.object({ kind: z.literal('group'), id: z.string(), members: z.array(z.string()) }), +]); + +@Tool({ name: 'resolve_principal', inputSchema, outputSchema }) +class ResolvePrincipalTool extends ToolContext { + async execute(input: { handle: string }) { + return { kind: 'user' as const, id: 'u_1', name: 'Ada' }; + } +} +``` + +`z.object()` is fine here — it's only the **top-level `inputSchema`** that must be a raw shape, not `outputSchema`. + +## Primitive literals + +For single-value outputs: + +```typescript +@Tool({ name: 'add', inputSchema: { a: z.number(), b: z.number() }, outputSchema: 'number' }) +class AddTool extends ToolContext { + execute(input: { a: number; b: number }): number { + return input.a + input.b; + } +} + +@Tool({ name: 'now', inputSchema: {}, outputSchema: 'date' }) +class NowTool extends ToolContext { + execute(): Date { + return new Date(); + } +} +``` + +## Media literals + +Binary content or links to MCP resources: + +```typescript +@Tool({ name: 'render_chart', inputSchema, outputSchema: 'image' }) +class RenderChartTool extends ToolContext { + async execute(input: ChartInput): Promise<{ data: string; mimeType: string }> { + return { + data: 'iVBORw0KGgoAAAANSU…', // base64 + mimeType: 'image/png', + }; + } +} +``` + +| Literal | Return shape | +| ----------------- | ----------------------------------------------------------------------- | +| `'image'` | `{ data: base64String, mimeType: 'image/png' \| 'image/jpeg' \| … }` | +| `'audio'` | `{ data: base64String, mimeType: 'audio/wav' \| 'audio/mpeg' \| … }` | +| `'resource'` | `{ uri: 'custom://…', mimeType?, text? \| blob? }` (inline resource) | +| `'resource_link'` | `{ uri: 'custom://…' }` (link only — host fetches via `resources/read`) | + +See [`26-tool-with-resource-link-output`](../examples/26-tool-with-resource-link-output.md) for the resource-link pattern. + +## Multi-content arrays + +Some tools return more than one block — e.g. a text summary plus an image: + +```typescript +@Tool({ name: 'analyze_image', inputSchema, outputSchema: ['string', 'image'] }) +class AnalyzeImageTool extends ToolContext { + async execute(input: { imageUrl: string }): Promise<[string, { data: string; mimeType: string }]> { + const summary = 'Detected: 2 people, 1 cat.'; + const annotated = await this.annotate(input.imageUrl); + return [summary, { data: annotated, mimeType: 'image/png' }]; + } +} +``` + +## Why this matters + +- **Data leak prevention** — without `outputSchema`, accidentally returning `{ result, internalApiKey }` leaks the key. With it, only `result` flows. +- **CodeCall compatibility** — the CodeCall plugin uses `outputSchema` to chain tool calls in its VM. Tools without it degrade chain-ability. +- **Compile-time type safety** — `ToolContext` infers `execute()`'s return type from `outputSchema` (when `ToolOutputOf<>` is used). The compiler catches divergence at build time. +- **Self-documenting** — `tools/list` exposes the output structure; AI clients pick tools partly based on what they return. + +## See also + +- [`input-schema.md`](./input-schema.md) +- [`derived-types.md`](./derived-types.md) +- [`rules/always-define-output-schema.md`](../rules/always-define-output-schema.md) diff --git a/libs/skills/catalog/create-tool/references/quick-start.md b/libs/skills/catalog/create-tool/references/quick-start.md new file mode 100644 index 000000000..7fffe3da0 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/quick-start.md @@ -0,0 +1,116 @@ +--- +name: quick-start +description: 60-second tour — minimal tool, schemas, registration, calling it. +--- + +# Quick start + +Goal: a working tool in five files (schema, tool, app, server, spec) in 60 seconds. + +## 1. The schemas (single source of truth) + +```typescript +// src/apps/main/tools/greet-user.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { + name: z.string().describe('The name of the user to greet'), +}; + +export const outputSchema = { + greeting: z.string(), +}; + +export type GreetUserInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GreetUserOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +## 2. The tool + +```typescript +// src/apps/main/tools/greet-user.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { inputSchema, outputSchema, type GreetUserInput, type GreetUserOutput } from './greet-user.schema'; + +@Tool({ + name: 'greet_user', + description: 'Greet a user by name', + inputSchema, + outputSchema, +}) +export class GreetUserTool extends ToolContext { + async execute(input: GreetUserInput): Promise { + return { greeting: `Hello, ${input.name}!` }; + } +} +``` + +## 3. The app + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { GreetUserTool } from './tools/greet-user.tool'; + +@App({ + name: 'main', + tools: [GreetUserTool], +}) +export class MainApp {} +``` + +## 4. The server + +```typescript +// src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; + +import { MainApp } from './apps/main'; + +@FrontMcp({ + info: { name: 'demo', version: '1.0.0' }, + apps: [MainApp], +}) +export default class DemoServer {} +``` + +## 5. The test + +```typescript +// src/apps/main/tools/greet-user.tool.spec.ts +import { testTool } from '@frontmcp/testing'; + +import { GreetUserTool } from './greet-user.tool'; + +describe('GreetUserTool', () => { + it('greets the user', async () => { + const result = await testTool(GreetUserTool).call({ name: 'Ada' }); + expect(result).toEqual({ greeting: 'Hello, Ada!' }); + }); +}); +``` + +## Run it + +```bash +yarn dev # starts the server +yarn test # runs the spec +``` + +## What you just did + +- Hoisted the **schemas** to their own file (so specs / generated clients can reuse them). +- Derived `execute()`'s **input/output types** from the schemas via `ToolInputOf<>` / `ToolOutputOf<>`. The schema is the single source of truth — change a Zod field and the type follows. +- Used a **raw Zod shape** for `inputSchema` (not `z.object({...})`) — the framework wraps it internally. +- Always defined an **`outputSchema`**. Without it, any field your code accidentally returns leaks to the client. +- Registered the tool in an **`@App({ tools })`**, not directly on `@FrontMcp` — apps own modularity and per-app lifecycle / auth. +- Wrote a **`.tool.spec.ts`** unit test using `@frontmcp/testing` — happy-path coverage from day one. + +## What's next + +- Add a real implementation → read [`execution-context.md`](./execution-context.md) for `this.get`, `this.fetch`, `this.notify`. +- Return structured / media / multi-content output → [`output-schema.md`](./output-schema.md). +- Make it interactive with a UI widget → [`ui-widgets.md`](./ui-widgets.md). +- Pick an example matching your scenario from [`SKILL.md` § Scenario routing table](../SKILL.md#scenario-routing-table). diff --git a/libs/skills/catalog/create-tool/references/registration.md b/libs/skills/catalog/create-tool/references/registration.md new file mode 100644 index 000000000..083fb80a4 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/registration.md @@ -0,0 +1,132 @@ +--- +name: registration +description: @App({ tools }) vs @FrontMcp({ tools }), multi-app composition. +--- + +# Registering tools + +## Best practice — register in `@App` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { AddNumbersTool, GreetUserTool, SearchDocumentsTool } from './tools'; + +@App({ + name: 'main', + tools: [GreetUserTool, SearchDocumentsTool, AddNumbersTool], +}) +export class MainApp {} +``` + +```typescript +// src/main.ts +import { FrontMcp } from '@frontmcp/sdk'; + +import { MainApp } from './apps/main'; + +@FrontMcp({ + info: { name: 'demo', version: '1.0.0' }, + apps: [MainApp], +}) +export default class DemoServer {} +``` + +Apps provide: + +- **Modularity** — each app is a self-contained surface; you can install / uninstall / disable apps without touching the others. +- **Per-app auth** — different apps can use different `auth: { mode: 'public' | 'transparent' | 'local' | 'remote' }`. +- **Per-app providers** — DI tokens registered in `@App({ providers })` are visible only to tools in that app. +- **Per-app lifecycle hooks** — `onAppStart`, `onAppStop`, etc. + +## Escape hatch — top-level `@FrontMcp({ tools })` + +For single-app servers, you can register tools directly on `@FrontMcp` instead of declaring an `@App`: + +```typescript +@FrontMcp({ + info: { name: 'demo', version: '1.0.0' }, + tools: [GreetUserTool, SearchDocumentsTool], +}) +export default class DemoServer {} +``` + +`@FrontMcp` accepts the same arrays as `@App`: `tools`, `resources`, `prompts`, `providers`, `plugins`, `jobs`, `channels`, `authorities`, `skills`. + +Use this for prototypes / very small servers. Promote to an `@App` as soon as you want any of the per-app benefits above. + +See [`rules/register-in-app.md`](../rules/register-in-app.md). + +## Multi-app composition + +Real-world servers have multiple apps. Each app owns its own tools, providers, and (optionally) auth mode: + +```typescript +@FrontMcp({ + info: { name: 'company-mcp', version: '1.0.0' }, + apps: [PublicApp, AuthenticatedApp, AdminApp], +}) +export default class CompanyServer {} + +@App({ + name: 'public', + auth: { mode: 'public', anonymousScopes: ['read:public'] }, + tools: [SearchPublicDocsTool], +}) +class PublicApp {} + +@App({ + name: 'authenticated', + auth: { mode: 'remote', clientId: process.env.OAUTH_CLIENT_ID }, + tools: [GetMyProfileTool, UpdateMyProfileTool], +}) +class AuthenticatedApp {} + +@App({ + name: 'admin', + auth: { mode: 'remote', requiredScopes: ['admin'] }, + tools: [DeleteUserTool, GrantRoleTool], +}) +class AdminApp {} +``` + +Tool names must be unique **across the whole server** — even though they live in different apps. The tool name is the lookup key in `tools/call`. + +## Tool sharing across apps + +Same tool registered in two apps: + +```typescript +@App({ name: 'public', tools: [SearchTool] }) +@App({ name: 'authenticated', tools: [SearchTool] }) +``` + +This is fine — the tool instance is constructed per-scope. Each app sees its own. But you'll want different names if the auth posture differs: + +```typescript +@App({ name: 'public', tools: [SearchPublicTool] }) +@App({ name: 'authenticated', tools: [SearchPrivateTool] }) +``` + +## Conditional registration + +For tools that should only register in certain envs: + +```typescript +@App({ + name: 'main', + tools: [ + GreetUserTool, + ...(process.env.NODE_ENV !== 'production' ? [DebugTool] : []), + ], +}) +``` + +Better — use `availableWhen: { env: ['development', 'test'] }` on the tool itself. Same effect, plus the constraint is self-documenting on the tool. + +## See also + +- [`rules/register-in-app.md`](../rules/register-in-app.md) +- [`availability.md`](./availability.md) +- `architecture` skill — multi-app patterns, module boundaries, scope / DI tokens diff --git a/libs/skills/catalog/create-tool/references/remote-and-esm.md b/libs/skills/catalog/create-tool/references/remote-and-esm.md new file mode 100644 index 000000000..8790e87c2 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/remote-and-esm.md @@ -0,0 +1,68 @@ +--- +name: remote-and-esm +description: Tool.esm / Tool.remote — load tools from ESM URLs or remote MCP servers. +--- + +# Remote and ESM tools + +Two ways to register tools you don't ship directly in your codebase: + +## `Tool.esm(...)` — ESM URL + +Loads a tool implementation from an ES module published to npm or hosted on a CDN. + +```typescript +const RemoteTool = Tool.esm('@my-org/tools@^1.0.0', 'MyTool', { + description: 'A tool loaded from an ES module', +}); + +@App({ name: 'main', tools: [RemoteTool] }) +class MainApp {} +``` + +| Arg | Purpose | +| ------------ | -------------------------------------------------------------------------------------------------------------------- | +| `specifier` | npm package + optional semver range (`'@my-org/tools@^1.0.0'`) OR a full URL (`'https://esm.sh/@acme/widget@2.1.0'`) | +| `exportName` | Named export to load from the module | +| `options` | Optional override for description / annotations / throttling — the module's defaults are used otherwise | + +The framework loads the module at server startup. Compatibility tip: the loaded module should export a `@Tool`-decorated class or a `tool({...})(handler)` value. + +## `Tool.remote(...)` — remote MCP server + +Proxies a tool from another MCP server. Tool calls hop through your server to the remote. + +```typescript +const CloudTool = Tool.remote('https://example.com/tools/cloud-tool', 'CloudTool', { + description: 'A tool loaded from a remote MCP server', +}); + +@App({ name: 'main', tools: [CloudTool] }) +class MainApp {} +``` + +| Arg | Purpose | +| ----------- | ------------------------------------------ | +| `serverUrl` | Remote MCP server URL | +| `toolName` | The remote tool's `name` | +| `options` | Local overrides (description, annotations) | + +The framework establishes a long-lived connection to the remote server at startup and re-uses it for every call. Auth headers from your server's session can be forwarded — configure via the remote-server registration in `@FrontMcp({ remoteServers: [...] })`. + +## When to use + +| Pattern | When | +| ---------------------------- | ------------------------------------------------------------------------------------------ | +| `@Tool` (class in your code) | Your tool, your code. The default. | +| `Tool.esm(...)` | Third-party tool packages, internal monorepo tools served via a CDN, shared tool libraries | +| `Tool.remote(...)` | Federation — your server exposes a tool that physically lives on another MCP server | + +## Limitations + +- **`Tool.esm`**: the loaded module runs in the same Node process. You inherit its dependencies. Pin versions; don't `^` against untrusted modules. +- **`Tool.remote`**: a remote outage means the proxied tool fails. Pair with `timeout` and consider a fallback. Auth headers may or may not be forwarded depending on your federation config. + +## See also + +- [`registration.md`](./registration.md) +- [`decorator-options.md`](./decorator-options.md) diff --git a/libs/skills/catalog/create-tool/references/testing.md b/libs/skills/catalog/create-tool/references/testing.md new file mode 100644 index 000000000..0e31bb263 --- /dev/null +++ b/libs/skills/catalog/create-tool/references/testing.md @@ -0,0 +1,59 @@ +--- +name: testing +description: Per-tool unit tests — pointer to the dedicated `testing` skill for the canonical patterns. +--- + +# Per-tool unit testing + +Every tool ships with `.tool.spec.ts`. The canonical test surface lives in the dedicated **`testing` skill** — this reference is a short pointer plus the per-tool checklist. + +## What `@frontmcp/testing` actually exposes + +| Surface | Purpose | +| ------------------------------------------------------------ | ---------------------------------------------------------- | +| `TestServer` (+ `TestServerOptions`) | Boot a real `@FrontMcp({...})` server in-process for tests | +| `McpTestClient` (+ `McpTestClientBuilder`) | Client to talk to the test server | +| `test` (Playwright-style fixture) + `expect` + `mcpMatchers` | Test runner and matchers | +| `mockResponse`, `httpMock`, `httpResponse`, `interceptors` | HTTP / outbound mocking | +| `TestUsers`, `TestTokenFactory`, `AuthHeaders` | Auth fixtures for authenticated tools | +| `MockOAuthServer`, `MockCimdServer`, `MockAPIServer` | Mocks for end-to-end auth flows | + +The `testing` skill is where you'll find: + +- The canonical `test({ mcp }) => …` fixture pattern. +- How to register a single tool / app for a focused test scope. +- How to assert MCP responses via `mcpMatchers` (`toHaveRenderedHtml`, `toBeXssSafe`, `toContainBoundValue`, etc.). +- How to mock outbound HTTP with `httpMock` (so `this.fetch` returns deterministic responses). +- How to inject mock providers for DI tokens. +- How to drive interactive `this.elicit` flows from a test. +- How to assert `this.progress` / `this.notify` event streams. +- E2E patterns (subprocess CLI exec, real-port transports — those live in `apps/e2e/demo-e2e-*/`, not in per-library specs). + +## Per-tool unit test — what to cover + +For every tool, the spec covers (at minimum): + +- [ ] **Happy path** — typical valid input → expected output, with `mcpMatchers` asserting the shape of the response. +- [ ] **Input-validation rejection** — Zod constraints fire (`min`, `max`, `regex`, `enum`). +- [ ] **At least one business-logic failure path** — `this.fail(new SomeMcpError(...))` triggers, the client sees the right MCP error code. +- [ ] **Mocked DI** — providers swapped via the testing harness; verify the tool calls the right service methods. +- [ ] **(If `this.fetch`)** — outbound HTTP mocked via `httpMock`, asserting URL / headers / body. +- [ ] **(If `this.elicit`)** — pre-arrange the elicitation response in the test harness and assert the `accept` / `decline` / `cancel` branches. +- [ ] **(If `this.progress`)** — assert the progress events fire with the expected `current` / `total` values. + +## File naming + +- `.tool.spec.ts` — never `.test.ts` (the repo enforces `.spec.ts`). +- Co-located with the tool source — `src/apps//tools//.tool.spec.ts` (folder-per-tool layout) OR `src/apps//tools/.tool.spec.ts` (flat sibling). + +## Don't + +- Don't boot the full real server (production transport, real auth, real DBs) for a per-tool unit test. Use the testing skill's focused fixtures. +- Don't assert the precise text of `PublicMcpError` messages. Assert the error class / `errorCode` (e.g. `'RATE_LIMIT_EXCEEDED'`, `'RESOURCE_NOT_FOUND'`) so the test isn't brittle to wording. +- Don't put end-to-end tests in per-library `__tests__/`. They belong in `apps/e2e/demo-e2e-*/` per the e2e-tests-location rule. + +## See also + +- `testing` skill — the canonical fixtures, mock patterns, and matchers +- [`error-handling.md`](./error-handling.md) — error codes to assert against +- [`execution-context.md`](./execution-context.md) — what `this.*` does, so you know what to mock diff --git a/libs/skills/catalog/create-tool/references/throttling.md b/libs/skills/catalog/create-tool/references/throttling.md new file mode 100644 index 000000000..14adc421e --- /dev/null +++ b/libs/skills/catalog/create-tool/references/throttling.md @@ -0,0 +1,103 @@ +--- +name: throttling +description: rateLimit, concurrency, timeout — semantics, interaction, defaults. +--- + +# Throttling — `rateLimit`, `concurrency`, `timeout` + +Three independent controls on `@Tool({...})`. Apply them when the tool calls expensive services, holds a scarce resource, or could legitimately hang. + +## `rateLimit` + +Cap invocations over a time window. + +```typescript +@Tool({ + name: 'send_notification', + rateLimit: { maxRequests: 100, windowMs: 60_000 }, // 100 calls / minute + // … +}) +``` + +- **Scope**: per-session by default. The session ID is the rate-limit key. To rate-limit globally, set `scope: 'global'` (be sure the server can absorb the burst). +- **Behavior on overflow**: the tool call returns a `RateLimitError` (HTTP status 429, MCP error code `'RATE_LIMIT_EXCEEDED'`). The error's payload carries a retry-after hint so clients can back off intelligently. +- **No half-allowed**: a call either counts fully toward the limit or doesn't run at all. Long-running calls don't block the window — only the start counts. + +## `concurrency` + +Cap simultaneous in-flight executions. + +```typescript +@Tool({ + name: 'render_pdf', + concurrency: { maxConcurrent: 5 }, // at most 5 PDFs rendering at once + // … +}) +``` + +- **Scope**: server-wide by default. Concurrency caps the resource — there's no point in per-session concurrency for shared resources like CPU / GPU / DB connection pools. +- **Behavior on overflow**: the call **queues** until a slot opens. Queue depth is unbounded by default; pair with a `timeout` to avoid pathological backups. +- **Use for**: tools that hold a real bottleneck — image / PDF rendering, ML inference, DB write transactions. + +## `timeout` + +Hard deadline on a single execution. + +```typescript +@Tool({ + name: 'long_query', + timeout: { executeMs: 30_000 }, // 30s + // … +}) +``` + +- **Scope**: per call. Wraps the entire `execute()` invocation. +- **Behavior on timeout**: the framework throws a `ToolTimeoutError` (subclass of `PublicMcpError`) and emits a `notifications/cancelled` for any progress token. The tool's `execute()` is signaled via the AbortSignal you can read from `this.context.abortSignal` — propagate it to `this.fetch` and any child operations. +- **Default**: no timeout. Tools can hang forever unless `timeout` is set. + +## Interaction + +The three controls are **orthogonal** — they apply independently: + +```typescript +@Tool({ + name: 'expensive_operation', + rateLimit: { maxRequests: 10, windowMs: 60_000 }, // ≤10 starts / min + concurrency: { maxConcurrent: 2 }, // ≤2 simultaneous + timeout: { executeMs: 30_000 }, // ≤30s each +}) +``` + +Order of effects per call: + +1. `rateLimit` checked → reject early if over limit (no concurrency slot consumed) +2. `concurrency` checked → queue if all slots taken +3. `timeout` armed → wraps `execute()` once it runs + +## Common combinations + +| Scenario | Recipe | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Spammy external API | `rateLimit: { maxRequests: 60, windowMs: 60_000 }` (60/min) | +| Shared DB / GPU | `concurrency: { maxConcurrent: 5 }` | +| Anything calling LLMs / 3rd-party HTTP | `timeout: { executeMs: 30_000 }` | +| All three | `rateLimit: { maxRequests: 10, windowMs: 60_000 }, concurrency: { maxConcurrent: 2 }, timeout: { executeMs: 30_000 }` | + +## Propagating the abort signal + +When a `timeout` fires, `execute()` receives an AbortSignal via `this.context.abortSignal`. Propagate it to abort in-flight work: + +```typescript +async execute(input: { url: string }) { + const response = await this.fetch(input.url, { signal: this.context.abortSignal }); + return response.json(); +} +``` + +`this.fetch` propagates the signal automatically if you don't pass one — but explicit is safer for nested fetches. + +## See also + +- [`decorator-options.md`](./decorator-options.md) +- [`error-handling.md`](./error-handling.md) +- [`execution-context.md`](./execution-context.md) diff --git a/libs/skills/catalog/create-tool/references/ui-widgets.md b/libs/skills/catalog/create-tool/references/ui-widgets.md new file mode 100644 index 000000000..0a17b32fc --- /dev/null +++ b/libs/skills/catalog/create-tool/references/ui-widgets.md @@ -0,0 +1,160 @@ +--- +name: ui-widgets +description: @Tool({ ui }) — template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec. +--- + +# Tool UI widgets + +The `ui:` field on `@Tool({...})` attaches an HTML widget to the tool's response. Supported hosts (OpenAI Apps SDK, Claude Artifacts, MCP Inspector) render the widget in a sandboxed iframe alongside the JSON output, using the MCP Apps extension ([SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)) and the `ui://widget/{toolName}.html` resource URI scheme. + +## Quick recipe + +```typescript +import { fileURLToPath } from 'node:url'; + +const widgetPath = fileURLToPath(new URL('./weather.widget.tsx', import.meta.url)); + +@Tool({ + name: 'get_weather', + description: 'Current weather for a city', + inputSchema, + outputSchema, + ui: { + template: { file: widgetPath }, + widgetDescription: 'Current weather card', + }, +}) +class GetWeatherTool extends ToolContext { + async execute(input: GetWeatherInput): Promise { + /* … */ + } +} +``` + +That's it. The framework: + +- Pre-compiles the widget at startup, registers it at `ui://widget/get_weather.html`. +- Auto-detects the connecting client — `resourceMode: 'inline'` for Claude (React bundled in), `'cdn'` for OpenAI / ChatGPT / Cursor (esm.sh import map, smaller payload). +- Emits `ui.csp` (if set) on the resource's `_meta.ui.csp` — Claude actually honors it (it ignores CSP declared on the tool). + +## Template formats + +| Format | Shape | When | +| ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| **FileSource (recommended)** | `{ file: widgetPath }` | `.tsx` / `.jsx` / `.html` source files. Anchor with `import.meta.url`. | +| **Function** | `(ctx) => string` | Quick demo / one-liner HTML. Annotate `ctx: TemplateContext` ([why](#typescript-gotcha-ts7006)). | +| **HTML / MDX string** | `'
'` or `'# Title\n'` | Static markup; pair with `mdxComponents` for MDX. | +| **React component** | `MyWidget` | SSR React. Set `hydrate: false` (default) for Claude/ChatGPT. | + +The renderer auto-detects which one you passed. + +## TypeScript gotcha (TS7006) + +Inline `template: (ctx) => …` under `strict` fails with `Parameter 'ctx' implicitly has an 'any' type` — `ui.template` is a union of multiple callable shapes so TypeScript can't pick a contextual type. Annotate explicitly: + +```typescript +import { type TemplateContext } from '@frontmcp/sdk'; + +ui: { + template: (ctx: TemplateContext) => + `
${ctx.helpers.escapeHtml(ctx.output.label)}
`, +} +``` + +Or use the FileSource form — it sidesteps the issue. + +## `ToolUIConfig` fields + +| Field | Default | Purpose | +| --------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `template` | — | Required. Function / HTML-string / React component / `{ file }` FileSource. | +| `widgetDescription` | — | Human-readable description surfaced to the host UI. | +| `servingMode` | `'auto'` | `'inline'` / `'static'` / `'hybrid'` / `'direct-url'` / `'custom-url'`. `'auto'` picks the best per-host. | +| `displayMode` | `'inline'` | `'inline'` / `'fullscreen'` / `'pip'` — host display hint. | +| `csp` | — | `{ connectDomains?, resourceDomains? }` — emitted on the resource content's `_meta.ui.csp` (#455). Claude honors CSP only here. | +| `contentSecurity` | strict | `{ allowUnsafeLinks?, allowInlineScripts?, bypassSanitization? }` — keep defaults. | +| `widgetAccessible` | `false` | `true` exposes `window.FrontMcpBridge.callTool` in the widget. | +| `resourceUri` | auto | Override the `ui://widget/{toolName}.html` URI. | +| `uiType` | `'auto'` | Force `'html'` / `'react'` / `'mdx'` / `'markdown'`. | +| `resourceMode` | host-detect | `'cdn'` / `'inline'`. Leave unset — the framework host-detects (Claude → `'inline'`, #456). | +| `hydrate` | `false` | Enable React hydration after SSR. Off by default — avoids React error #418 in Claude. | +| `externals`, `dependencies` | — | CDN externals for FileSource widgets. | +| `customShell`, `invocationStatus`, `widgetCapabilities`, `prefersBorder`, `sandboxDomain`, `htmlResponsePrefix` | — | Platform-specific knobs. | + +## Path resolution gotcha (#444) + +Bare `template: { file: './widget.tsx' }` resolves against `process.cwd()`, **not** the tool file. Always anchor: + +```typescript +import { fileURLToPath } from 'node:url'; + +const widgetPath = fileURLToPath(new URL('./weather.widget.tsx', import.meta.url)); +ui: { + template: { + file: widgetPath, + }, +} +``` + +See [`rules/widget-paths-anchor-with-import-meta-url.md`](../rules/widget-paths-anchor-with-import-meta-url.md). + +## `@frontmcp/ui` prerequisite (#443) + +`.tsx` / `.jsx` FileSource widgets require `@frontmcp/ui` in the consuming project — the bundler injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`: + +```bash +npm install @frontmcp/ui +# or: yarn add @frontmcp/ui / pnpm add @frontmcp/ui +``` + +Match the version to `@frontmcp/sdk`. Without it, server-side bundling fails with a friendly error pointing at this requirement. + +## Widget bridge — `window.FrontMcpBridge` + +When the widget needs to read tool data or invoke other tools, the bridge IIFE is injected automatically. Set `widgetAccessible: true` to enable `callTool`: + +```typescript +ui: { + template: (ctx) => ` + + + `, + widgetAccessible: true, +} +``` + +| Bridge method | Purpose | +| --------------------------------------------------------------- | ------------------------------------------------------- | +| `callTool(name, args)` | Invoke another tool (requires `widgetAccessible: true`) | +| `getToolInput()` / `getToolOutput()` / `getStructuredContent()` | Read the tool data | +| `getWidgetState()` / `setWidgetState(state)` | Persisted per-widget state | +| `getHostContext()` / `getTheme()` / `getDisplayMode()` | Host context | +| `hasCapability(cap)` | Probe adapter capabilities | +| `onToolResponseMetadata(cb)` | Subscribe to `ui/html` arrival (inline mode) | + +The bridge routes to the right host adapter (OpenAI SDK / Claude postMessage / FrontMCP direct) automatically. **Never call `window.openai.*` directly** — it works on OpenAI but breaks everywhere else. + +## Host considerations + +| Host | Notes | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **OpenAI Apps SDK** | Any CDN works. `_meta['openai/outputTemplate']` advertises the widget. | +| **Claude (MCP-UI)** | Widget iframe blocks all external script execution. Use `resourceMode: 'inline'` (auto-detected when you leave it unset) so React bundles in. CSP must be on the resource — framework handles it via `ui.csp` (#455 fix). | +| **MCP Inspector** | Useful for local development. Static mode works fine. | +| **Gemini / unknown** | `ui` is ignored — JSON output is returned. | + +## Examples + +- [`22-tool-with-ui-html-template`](../examples/22-tool-with-ui-html-template.md) — inline function template +- [`23-tool-with-ui-filesource-tsx`](../examples/23-tool-with-ui-filesource-tsx.md) — `.tsx` widget, host-detect +- [`24-tool-with-ui-csp-and-bridge`](../examples/24-tool-with-ui-csp-and-bridge.md) — CSP + `widgetAccessible` + bridge + +## Related rules + +- [`rules/widget-paths-anchor-with-import-meta-url.md`](../rules/widget-paths-anchor-with-import-meta-url.md) +- [`rules/widget-resource-mode-host-detect.md`](../rules/widget-resource-mode-host-detect.md) diff --git a/libs/skills/catalog/create-tool/rules/always-define-output-schema.md b/libs/skills/catalog/create-tool/rules/always-define-output-schema.md new file mode 100644 index 000000000..e76396d70 --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/always-define-output-schema.md @@ -0,0 +1,77 @@ +--- +name: always-define-output-schema +constraint: Every `@Tool` defines `outputSchema`. +severity: required +--- + +# Rule: every tool defines `outputSchema` + +## The rule + +Every `@Tool({...})` block declares `outputSchema`. There is no acceptable case for omitting it on a production tool. + +## Good + +```typescript +@Tool({ + name: 'get_weather', + description: 'Current weather for a city', + inputSchema: { city: z.string() }, + outputSchema: { + temperatureF: z.number(), + conditions: z.string(), + }, +}) +class GetWeatherTool extends ToolContext { + async execute(input: { city: string }) { + const apiResponse = await this.fetch(`https://api.example.com/weather?city=${input.city}`); + const data = await apiResponse.json(); + // Even though `data` contains { temperatureF, conditions, internalApiKey, debugTrace, … } + // — the outputSchema strips everything except `temperatureF` and `conditions`. + return data; + } +} +``` + +## Bad + +```typescript +// ❌ no outputSchema — every field in `data` flows through to the client +@Tool({ + name: 'get_weather', + description: 'Current weather for a city', + inputSchema: { city: z.string() }, +}) +class GetWeatherTool extends ToolContext { + async execute(input: { city: string }) { + const apiResponse = await this.fetch(`https://api.example.com/weather?city=${input.city}`); + return apiResponse.json(); // ← internalApiKey, debugTrace, PII … all leak + } +} +``` + +## Why + +1. **Output validation prevents data leaks.** Without `outputSchema`, every field your code accidentally includes (or that an upstream API drops in unsolicited — auth tokens, internal IDs, debug traces, PII) reaches the MCP client. With it, only declared fields pass through; everything else is stripped. +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 calls. +3. **Type safety on `execute()`'s return type.** With `outputSchema` declared, `ToolContext` infers the expected return type from it. The compiler tells you when your return value diverges from the declared shape. +4. **Self-documenting tools.** `tools/list` exposes the output structure to AI clients; they can choose the right tool based on what it returns. + +## How to apply + +- For structured data: **Zod raw shape** is the recommended form — `{ field: z.string(), count: z.number() }`. Strict, validated, JSON-serializable. +- For a single primitive: use the literal — `outputSchema: 'string' | 'number' | 'boolean' | 'date'`. +- For media: `'image'`, `'audio'`, `'resource'`, `'resource_link'`. +- For multi-content: an array — `outputSchema: ['string', 'image']`. +- For complex types not expressible as a raw shape: full Zod schemas — `z.object(...)`, `z.discriminatedUnion([...])`, etc. (Note: this is the one place `z.object()` is allowed — it's _not_ allowed for `inputSchema`.) + +See [`output-schema.md`](../references/output-schema.md) for the full taxonomy. + +## Verification + +```bash +# Grep for tools without outputSchema — should return 0 hits +grep -L 'outputSchema:' $(grep -rl '@Tool' --include='*.tool.ts' src/) +``` + +A failing CI step that runs this grep is the cheapest way to enforce the rule across a codebase. diff --git a/libs/skills/catalog/create-tool/rules/derive-execute-types.md b/libs/skills/catalog/create-tool/rules/derive-execute-types.md new file mode 100644 index 000000000..e0aa22520 --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/derive-execute-types.md @@ -0,0 +1,57 @@ +--- +name: derive-execute-types +constraint: '`execute()` parameter and return types come from `ToolInputOf<>` / `ToolOutputOf<>` — never duplicated inline.' +severity: required +--- + +# Rule: derive `execute()` types from the schemas + +## The rule + +`execute()`'s parameter and return types are derived from the hoisted schemas via `ToolInputOf<>` / `ToolOutputOf<>`. Hand-typing the shape inline next to the schema is a second declaration of the same contract. + +## Good + +```typescript +// .schema.ts +export const inputSchema = { city: z.string() }; +export const outputSchema = { temperatureF: z.number() }; +export type GetWeatherInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GetWeatherOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; + +// .tool.ts +async execute(input: GetWeatherInput): Promise { + return { temperatureF: 72 }; +} +``` + +## Bad + +```typescript +// ❌ inline annotation duplicates the schema's shape +async execute(input: { city: string }): Promise<{ temperatureF: number }> { + return { temperatureF: 72 }; +} +``` + +Why it's bad: change the schema (`city` → `location`, add `units`) without touching the annotation and TypeScript happily compiles. Runtime Zod validation rejects the request that the compiler accepted. + +## Why + +- **Single source of truth** — the schema defines the contract. Types derived from it can't drift. Hand-typed shapes silently rot when the schema changes. +- **Re-importable** — specs, sibling tools, and generated clients all `import { GetWeatherInput }` from the same `.schema.ts` file. They get one canonical type. +- **Compiler catches drift** — change a schema field, and the compiler flags every place that reads the old shape. Inline annotations defeat this. + +## How to apply + +- Always hoist `inputSchema` / `outputSchema` to `.schema.ts`. +- Always export `type Input = ToolInputOf<{ inputSchema: typeof inputSchema }>;` and `type Output = ToolOutputOf<{ outputSchema: typeof outputSchema }>;` next to them. +- Import the derived types into the tool file and use them on `execute()`. +- Form 2 (`z.infer>`) produces an identical type — pick whichever fits the surrounding code. + +## Verification + +```bash +# Find tool files where execute() uses an inline object literal type — likely a violation +grep -rE 'execute\(input:\s*\{' --include='*.tool.ts' src/ +``` diff --git a/libs/skills/catalog/create-tool/rules/input-schema-is-raw-shape.md b/libs/skills/catalog/create-tool/rules/input-schema-is-raw-shape.md new file mode 100644 index 000000000..75e7e4bd4 --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/input-schema-is-raw-shape.md @@ -0,0 +1,76 @@ +--- +name: input-schema-is-raw-shape +constraint: '`inputSchema` is a raw Zod shape, never `z.object(...)`.' +severity: required +--- + +# Rule: `inputSchema` is a raw Zod shape + +## The rule + +The top-level value of `inputSchema` is a plain object mapping field names to Zod types. The framework wraps it in `z.object(...)` internally and validates every call. + +## Good + +```typescript +@Tool({ + name: 'search', + inputSchema: { + query: z.string().min(1), + limit: z.number().int().min(1).max(100).default(10), + }, +}) +``` + +Nested `z.object({...})` INSIDE a field is fine — only the top-level value must be a raw shape: + +```typescript +inputSchema: { + user: z.object({ id: z.string(), name: z.string() }), // OK — nested + filter: z.array(z.string()), +} +``` + +## Bad + +```typescript +// ❌ wrapped at the top level +@Tool({ + name: 'search', + inputSchema: z.object({ + query: z.string(), + }), +}) + +// ❌ wrapped via z.union / z.intersection at the top +inputSchema: z.union([z.object({...}), z.object({...})]), +``` + +## Why + +- **Type inference** — `ToolInputOf<{ inputSchema: typeof inputSchema }>` and the SDK's decorator inference both assume the raw-shape form. Wrapping breaks both. +- **`@Tool` metadata** — the decorator reads `inputSchema` as a `Record` to compute the JSON schema published in `tools/list`. A wrapped `z.object` defeats this. +- **Consistency** — every tool in the codebase uses the same form. A wrapped `inputSchema` is an outlier that confuses readers and breaks grep-based code search. + +If you legitimately need a union or transform for input, do it inside `execute()`: + +```typescript +inputSchema: { + start: z.string().datetime(), + end: z.string().datetime(), +} + +async execute(input) { + if (input.start >= input.end) { + this.fail(new InvalidInputError('start must be before end')); + } + // … +} +``` + +## Verification + +```bash +# Any `inputSchema: z.` is a violation +grep -rE 'inputSchema:\s*z\.' src/**/*.tool.ts +``` diff --git a/libs/skills/catalog/create-tool/rules/no-toolcontext-generics.md b/libs/skills/catalog/create-tool/rules/no-toolcontext-generics.md new file mode 100644 index 000000000..555c0dd2f --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/no-toolcontext-generics.md @@ -0,0 +1,50 @@ +--- +name: no-toolcontext-generics +constraint: '`class MyTool extends ToolContext` — never `extends ToolContext`.' +severity: required +--- + +# Rule: don't parameterize `ToolContext` with explicit generics + +## The rule + +`ToolContext` infers input / output types from the `@Tool({...})` decorator at the **class** level automatically. Adding explicit generics is redundant — and prevents the inference from flowing correctly when the decorator's shape changes. + +## Good + +```typescript +@Tool({ name: 'greet', inputSchema, outputSchema }) +class GreetTool extends ToolContext { + async execute(input: GreetInput): Promise { + return { greeting: `Hello, ${input.name}!` }; + } +} +``` + +## Bad + +```typescript +// ❌ explicit generics — redundant and brittle +@Tool({ name: 'greet', inputSchema, outputSchema }) +class GreetTool extends ToolContext { + // … +} + +// ❌ partial generics — even worse, hides which way drift goes +class GreetTool extends ToolContext { + // … +} +``` + +## Why + +- **The decorator already infers.** `@Tool` carries the input/output schemas in its options; the SDK's decorator hooks the inferred types into the class. Explicit generics either match (redundant) or mismatch (silently break the inferred type). +- **Schema changes flow automatically.** With plain `extends ToolContext`, changing a Zod field in `.schema.ts` updates everything that uses `ToolInputOf` / `ToolOutputOf` — including the `execute()` annotation. With explicit generics, you have to remember to update them. +- **Consistency** — every tool in the codebase uses plain `extends ToolContext`. Explicit generics are an outlier that flag a misunderstanding of how the decorator works. + +## Verification + +```bash +# Any `extends ToolContext<` is a violation +grep -rn 'extends ToolContext<' src/**/*.tool.ts +``` diff --git a/libs/skills/catalog/create-tool/rules/no-try-catch-around-execute.md b/libs/skills/catalog/create-tool/rules/no-try-catch-around-execute.md new file mode 100644 index 000000000..559e56a3d --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/no-try-catch-around-execute.md @@ -0,0 +1,79 @@ +--- +name: no-try-catch-around-execute +constraint: 'Do not wrap the body of `execute()` in `try/catch`. The framework owns the error flow.' +severity: required +--- + +# Rule: don't `try/catch` around `execute()` + +## The rule + +The framework's tool-execution flow catches exceptions, formats them into proper JSON-RPC errors, runs error hooks, emits notifications. Wrapping the `execute()` body in a `try/catch` defeats all of that. + +## Good + +```typescript +async execute(input: Input) { + const record = await this.findRecord(input.id); + if (!record) { + this.fail(new ResourceNotFoundError(`record:${input.id}`)); // controlled error + } + return doWork(record); // any other throw propagates to the framework +} +``` + +## Bad + +```typescript +// ❌ swallows everything, hides infrastructure errors, breaks the framework's flow +async execute(input: Input) { + try { + const record = await this.findRecord(input.id); + return doWork(record); + } catch (err) { + this.fail(err instanceof Error ? err : new Error(String(err))); + } +} + +// ❌ even worse — silently returns a default and the client never sees the failure +async execute(input: Input) { + try { + return await doWork(input); + } catch { + return { ok: false }; // 💣 + } +} +``` + +## Why + +- **The framework's flow already catches.** It logs the error with full context, emits structured notifications, formats the JSON-RPC error with the right code, and runs error hooks (audit, metrics, telemetry). Wrapping defeats all of that. +- **Raw `Error` messages get redacted.** Without `PublicMcpError`, the framework treats the message as potentially-sensitive and replaces it with "Internal error". Manual try/catch + `this.fail(err)` strips the public-message guarantee. +- **Observability breaks.** Distributed-tracing spans, metrics counters, and audit logs all key off the framework-caught error. Manual try/catch hides the error from them. + +## The narrow exception + +If you have a specific failure mode the framework can't classify (a particular HTTP status, an upstream error code that means "not found" instead of "server error"), catch JUST THAT case and convert to `this.fail`: + +```typescript +async execute(input: Input) { + const response = await this.fetch(url, init); + if (response.status === 404) { + this.fail(new ResourceNotFoundError(`upstream:${input.id}`)); + } + if (!response.ok) { + this.fail(new PublicMcpError(`Upstream returned ${response.status}`)); + } + // 5xx, network errors, timeouts — let them propagate to the framework + return response.json(); +} +``` + +That's not wrapping the whole `execute()` — that's targeted conversion of one specific signal. Different shape, different purpose. + +## Verification + +```bash +# Find try/catch directly inside execute() — should return 0 hits +grep -rPzo '(?s)async execute\([^)]*\) \{.*?try \{' src/**/*.tool.ts +``` diff --git a/libs/skills/catalog/create-tool/rules/register-in-app.md b/libs/skills/catalog/create-tool/rules/register-in-app.md new file mode 100644 index 000000000..84e312008 --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/register-in-app.md @@ -0,0 +1,76 @@ +--- +name: register-in-app +constraint: 'Register tools in `@App({ tools })`, not directly on `@FrontMcp({ tools })` (the latter is the simple-server escape hatch).' +severity: recommended +--- + +# Rule: register tools in `@App` + +## The rule + +Register tools in an `@App({ tools })`. `@FrontMcp({ tools })` is the escape hatch for single-app prototypes — promote to an `@App` as soon as you want any of the per-app benefits. + +## Good + +```typescript +@App({ + name: 'main', + providers: [UserServiceProvider], + tools: [GreetUserTool, GetUserTool], +}) +class MainApp {} + +@FrontMcp({ + info: { name: 'demo', version: '1.0.0' }, + apps: [MainApp], +}) +export default class DemoServer {} +``` + +## Acceptable (single-app prototypes) + +```typescript +@FrontMcp({ + info: { name: 'demo', version: '1.0.0' }, + tools: [GreetUserTool], // top-level — fine for very small servers +}) +export default class DemoServer {} +``` + +## Why prefer `@App` + +| Top-level `@FrontMcp({ tools })` | `@App({ tools })` | +| --------------------------------------- | ----------------------------------------------------------------------------------------------- | +| One auth posture for the whole server | Per-app `auth: { mode: 'public' \| 'transparent' \| 'local' \| 'remote' }` | +| Providers visible to every tool | DI scope per app — tokens registered in `@App({ providers })` are visible only to its own tools | +| No per-app lifecycle | `onAppStart`, `onAppStop`, app-level hooks | +| Hard to refactor when you need to split | Already split | + +## When to promote to `@App` + +Whenever any of the following: + +- You want different auth modes for different parts of the surface (public vs authenticated vs admin) +- Tools share local services that other apps shouldn't see +- You want per-app lifecycle hooks +- You're past ~5 tools in one server + +Promoting from top-level to an `@App` is a one-line refactor: + +```typescript +// before +@FrontMcp({ tools: [A, B, C] }) + +// after +@App({ name: 'main', tools: [A, B, C] }) class MainApp {} +@FrontMcp({ apps: [MainApp] }) +``` + +## Severity + +This rule is `recommended`, not `required`. Single-app prototypes can stay on top-level `@FrontMcp({ tools })` indefinitely. The push to `@App` is about future-proofing for the moment you need any per-app concern — which usually arrives. + +## See also + +- `architecture` skill — multi-app patterns, module boundaries, DI scope +- [`references/registration.md`](../references/registration.md) diff --git a/libs/skills/catalog/create-tool/rules/snake-case-tool-names.md b/libs/skills/catalog/create-tool/rules/snake-case-tool-names.md new file mode 100644 index 000000000..2968601c8 --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/snake-case-tool-names.md @@ -0,0 +1,45 @@ +--- +name: snake-case-tool-names +constraint: 'Tool `name:` field is always `snake_case` (e.g. `get_weather`, not `getWeather`).' +severity: required +--- + +# Rule: tool names are `snake_case` + +## The rule + +The `name:` field on `@Tool({...})` is `snake_case`. Lowercase letters, digits, underscores. No camelCase, no kebab-case, no PascalCase. + +## Good + +```typescript +@Tool({ name: 'get_weather' }) +@Tool({ name: 'create_issue' }) +@Tool({ name: 'list_repos' }) +@Tool({ name: 'send_email' }) +@Tool({ name: 'rotate_secrets' }) +``` + +## Bad + +```typescript +@Tool({ name: 'getWeather' }) // ❌ camelCase +@Tool({ name: 'get-weather' }) // ❌ kebab-case +@Tool({ name: 'GetWeather' }) // ❌ PascalCase +@Tool({ name: 'GET_WEATHER' }) // ❌ uppercase +``` + +## Why + +- **MCP protocol convention.** Tool names are the lookup key for `tools/call` across the entire MCP ecosystem. Servers and clients consistently expect `snake_case`. +- **Cross-platform consistency.** Some clients (and LLM prompt templates) normalize tool names for display; `snake_case` survives roundtrips. Mixed-case can be folded inconsistently. +- **Searchable.** `git grep create_issue` finds every reference; `git grep create.issue` (regex required for cross-case) is more work. + +> The CLASS name stays `PascalCase` (`GetWeatherTool`). Only the `name:` _field_ is `snake_case`. The mismatch is intentional — class names follow TypeScript conventions, MCP tool names follow MCP conventions. + +## Verification + +```bash +# Find non-snake_case tool names — should return 0 hits +grep -rE "name:\s*'[^']*[A-Z-][^']*'" $(grep -rl '@Tool' src/**/*.tool.ts) +``` diff --git a/libs/skills/catalog/create-tool/rules/use-this-fail-for-business-errors.md b/libs/skills/catalog/create-tool/rules/use-this-fail-for-business-errors.md new file mode 100644 index 000000000..37fe877da --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/use-this-fail-for-business-errors.md @@ -0,0 +1,75 @@ +--- +name: use-this-fail-for-business-errors +constraint: '`this.fail(new SomeMcpError(...))` for business-logic errors — never raw `throw new Error(...)`.' +severity: required +--- + +# Rule: `this.fail` for business errors, not raw `throw` + +## The rule + +For errors the user / agent should see (not-found, permission-denied, invalid-input, conflict, etc.), use `this.fail(new SomeMcpError(...))`. Never `throw new Error(...)` — the raw `Error` message gets REDACTED before reaching the client. + +## Good + +```typescript +import { PublicMcpError, ResourceNotFoundError } from '@frontmcp/sdk'; + +async execute(input: { id: string }) { + const record = await this.findRecord(input.id); + if (!record) { + this.fail(new ResourceNotFoundError(`record:${input.id}`)); // -32002, message reaches client + } + + if (record.tenantId !== this.context.authInfo.tenantId) { + this.fail(new PublicMcpError('Access denied')); // generic public message + } + + // … +} +``` + +## Bad + +```typescript +// ❌ raw Error — message REDACTED to "Internal error" before reaching client +async execute(input: { id: string }) { + const record = await this.findRecord(input.id); + if (!record) { + throw new Error(`record:${input.id} not found`); // client sees "Internal error" + } +} +``` + +## Why + +| You throw | Client sees | +| -------------------------------------- | ------------------------------------------------------------ | +| `new PublicMcpError('Quota exceeded')` | `{ code: -32603, message: 'Quota exceeded' }` | +| `new ResourceNotFoundError('user:42')` | `{ code: -32002, message: 'user:42' }` | +| `new Error('Quota exceeded')` | `{ code: -32603, message: 'Internal error' }` ← **redacted** | + +Raw `Error`s are treated as potentially-sensitive infrastructure errors. The framework REDACTS the message before the client sees it (to avoid leaking stack traces, internal IDs, env vars, etc.). `PublicMcpError` (and subclasses) explicitly opt the message into the response. + +## Common error classes + +| Class | Error code | HTTP | Use for | +| -------------------------------- | ---------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| `PublicMcpError` | — | — | Base. Subclass for domain-specific public errors | +| `ResourceNotFoundError` | `'RESOURCE_NOT_FOUND'` (-32002 JSON-RPC) | 404 | "Thing doesn't exist" | +| `InvalidInputError` | `'INVALID_INPUT'` | 400 | Cross-field / business-rule input validation (Zod handles per-field shape) | +| `UnauthorizedError` | `'UNAUTHORIZED'` (-32001 JSON-RPC) | 401 | Missing credentials | +| `RateLimitError` | `'RATE_LIMIT_EXCEEDED'` | 429 | Rate-limit fired | +| Custom `PublicMcpError` subclass | your choice | your choice | Domain-specific errors with structured `data` | + +## When to throw raw `Error` (or just let it propagate) + +For **infrastructure errors** that genuinely should be redacted — network failure, DB unavailable, file-system error. Don't catch them; just let them propagate. The framework wraps them in `InternalMcpError` with the message redacted, and logs the original for ops. + +## Verification + +```bash +# Find raw `throw new Error(...)` inside tool execute() bodies +grep -rE 'throw new Error\(' src/**/*.tool.ts +# Should match few or none — and any matches should be in non-execute code paths +``` diff --git a/libs/skills/catalog/create-tool/rules/widget-paths-anchor-with-import-meta-url.md b/libs/skills/catalog/create-tool/rules/widget-paths-anchor-with-import-meta-url.md new file mode 100644 index 000000000..4207c1c34 --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/widget-paths-anchor-with-import-meta-url.md @@ -0,0 +1,59 @@ +--- +name: widget-paths-anchor-with-import-meta-url +constraint: '`.tsx` widget paths in `ui.template: { file }` are anchored via `fileURLToPath(new URL(...))`, never bare relative.' +severity: required +--- + +# Rule: anchor widget paths with `import.meta.url` + +## The rule + +Relative `FileSource` paths in `ui.template: { file }` resolve against `process.cwd()` — **not** the tool source's directory (issue #444). A bare relative path silently breaks the moment the server is launched from a different working directory. Always anchor the path to the tool source. + +## Good + +```typescript +import { fileURLToPath } from 'node:url'; + +const widgetPath = fileURLToPath(new URL('./sales-chart.widget.tsx', import.meta.url)); + +@Tool({ + name: 'sales_chart', + // … + ui: { template: { file: widgetPath } }, +}) +``` + +## Bad + +```typescript +// ❌ bare relative path — resolves against process.cwd() +@Tool({ + name: 'sales_chart', + ui: { template: { file: './sales-chart.widget.tsx' } }, +}) +// → works locally when running from src/apps/main/tools/ +// → fails with ENOENT when running from the repo root, from dist/, etc. +``` + +## Why + +- **`process.cwd()` is whoever launched the process.** `yarn dev` from the repo root, `node dist/main.js` from `/opt/app`, a containerized run from `/`, a serverless cold start from `/var/task`, an Nx executor from `apps//` — all different cwds. +- **Tool sources move around at build time.** ESM build output is often in `dist/`; `.tool.ts` becomes `.tool.js`. The relative reference's resolution chain is fragile to that. +- **`fileURLToPath(new URL('./x', import.meta.url))` is invariant.** It anchors to the **source file** that contains the URL literal — same answer at dev, build, and runtime. + +## Also: name the widget `*.widget.tsx` + +The scaffolded `tsconfig.json` excludes `**/*.widget.tsx` from the server typecheck (issue #445). Naming widgets `sales-chart.widget.tsx` keeps server `tsc --noEmit` happy without dragging React types into the server config. + +## Verification + +```bash +# Find any bare-relative `file:` literals in ui templates — should return 0 hits +grep -rE "file:\s*'\.\.?/[^']*\.tsx'" src/**/*.tool.ts +``` + +## See also + +- [`references/ui-widgets.md`](../references/ui-widgets.md) +- [`examples/23-tool-with-ui-filesource-tsx`](../examples/23-tool-with-ui-filesource-tsx.md) diff --git a/libs/skills/catalog/create-tool/rules/widget-resource-mode-host-detect.md b/libs/skills/catalog/create-tool/rules/widget-resource-mode-host-detect.md new file mode 100644 index 000000000..81916047f --- /dev/null +++ b/libs/skills/catalog/create-tool/rules/widget-resource-mode-host-detect.md @@ -0,0 +1,61 @@ +--- +name: widget-resource-mode-host-detect +constraint: 'Leave `ui.resourceMode` unset — the framework host-detects (`inline` for Claude, `cdn` for others).' +severity: recommended +--- + +# Rule: leave `ui.resourceMode` unset by default + +## The rule + +`ui.resourceMode` defaults to a host-detected value (issue #456): `'inline'` for Claude (React bundled into the widget so it renders under Claude's sandbox CSP), `'cdn'` for OpenAI / ChatGPT / Cursor / MCP Inspector (smaller payload from esm.sh). Leave the field unset unless you have a specific reason to override. + +## Good + +```typescript +@Tool({ + // … + ui: { + template: { file: widgetPath }, + // resourceMode intentionally UNSET — framework picks per host + }, +}) +``` + +## Bad (without justification) + +```typescript +// ❌ pinning to 'cdn' — breaks Claude (widget hangs on "Loading widget…") +ui: { template: { file: widgetPath }, resourceMode: 'cdn' } + +// ❌ pinning to 'inline' — fine for Claude, but always-larger payload for OpenAI / ChatGPT +ui: { template: { file: widgetPath }, resourceMode: 'inline' } +``` + +## When to override (with justification) + +- **Force `'inline'`** when the widget must work in a network-blocked environment beyond Claude (some kiosks, air-gapped demos, etc.). Pay the larger payload cost intentionally. +- **Force `'cdn'`** when you specifically know the widget will only be served to CDN-permissive clients AND you want minimum payload. Rare — usually the framework's choice is correct. +- **Set per call** at the tool layer is the wrong place — `resourceMode` is part of static widget compilation. Per-call decisions belong in `servingMode` instead. + +## Why + +- **Claude's iframe blocks external scripts.** Default `'cdn'` emits an esm.sh import map for React; Claude's CSP blocks it; the widget hangs forever on the FrontMCP "Loading widget…" placeholder. `'inline'` bundles React into the widget's ` - - `; - }, - }, -}) -class ShowQuoteTool extends ToolContext { - async execute(input: ShowQuoteInput): Promise { - const res = await this.fetch(`https://api.market.example.com/quote/${input.symbol}`); - const body = (await res.json()) as { price: number; ts: string }; - return { symbol: input.symbol, priceUsd: body.price, asOf: body.ts }; - } -} - -export { ShowQuoteTool }; -``` - -```typescript -// src/apps/main/index.ts -import { App } from '@frontmcp/sdk'; - -import { GetQuoteTool } from './tools/get-quote.tool'; -import { ShowQuoteTool } from './tools/show-quote.tool'; - -@App({ - name: 'main', - tools: [GetQuoteTool, ShowQuoteTool], -}) -class MainApp {} - -export { MainApp }; -``` - -## What This Demonstrates - -- Restricting widget network access with `csp.connectDomains` (CSP `connect-src`) -- Enabling tool invocation from the widget via `widgetAccessible: true` -- Calling another tool via `window.FrontMcpBridge.callTool(name, args)` instead of direct host APIs -- Using `ctx.helpers.jsonEmbed(...)` to safely pass JSON into an inline ``) | - -Always run user-controlled strings through `escapeHtml` (or rely on the default sanitizer — see [Content Security](#content-security)). - -## Serving Modes - -`ui.servingMode` controls how the widget HTML is delivered. **Default `'auto'` is what you want in almost all cases.** - -| Mode | Where HTML lives | Use when | -| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| `'auto'` | Picks `'inline'` for known UI clients, JSON-only for others | Default — let the SDK detect host capabilities | -| `'inline'` | Embedded in tool response `_meta['ui/html']` | Works on all UI hosts including network-blocked Claude Artifacts | -| `'static'` | Pre-compiled at startup; fetched via `resources/read` from `ui://widget/{toolName}.html` | OpenAI's template/discovery flow; widget doesn't change per call | -| `'hybrid'` | Shell pre-compiled; component code + data in `_meta['ui/component']` | React widgets that need a stable shell but per-call component code | -| `'direct-url'` | HTTP endpoint on the MCP server (path from `directPath`) | Avoid third-party-cookie issues (widget loads from your own origin) | -| `'custom-url'` | Custom URL (CDN or external host) | Widget hosted elsewhere; pair with `customWidgetUrl` (supports `{token}`) | - -```typescript -// Pre-compile at startup, expose as ui:// resource -ui: { template: MyWidget, servingMode: 'static' } - -// Serve from /widgets/weather on the MCP server itself -ui: { template: MyWidget, servingMode: 'direct-url', directPath: '/widgets/weather' } - -// CDN-hosted -ui: { - template: MyWidget, - servingMode: 'custom-url', - customWidgetUrl: 'https://cdn.example.com/widgets/weather.html?token={token}', -} -``` - -## Resource URI Scheme - -When `servingMode` is `'auto'` or `'static'`, FrontMCP registers a resource at: - -``` -ui://widget/{toolName}.html -``` - -You can override this with `resourceUri: 'ui://my-app/dashboard.html'`. The `ui://` URIs surface in: - -- `tools/list` — under `_meta['openai/outputTemplate']` and `_meta['ui/resource']` -- `resources/list` — as discoverable resources -- `resources/read` — returns the compiled widget HTML with `MCP_APPS_MIME_TYPE` - -The argument-completion flow (`completion/complete`) special-cases `ui://widget/` URIs to suggest tool names. Don't put non-widget content under that scheme. - -## Display Mode - -`ui.displayMode` is a hint to the host: - -| Value | Meaning | -| -------------- | ------------------------------------------- | -| `'inline'` | Render inline in the conversation (default) | -| `'fullscreen'` | Request fullscreen display | -| `'pip'` | Picture-in-picture | - -Hosts may ignore values they don't support. - -## Content Security - -Widgets render inside a double-iframe sandbox on both OpenAI and Claude: - -``` -Host ▶ Outer sandbox iframe (no parent cookies) - ▶ Inner widget iframe (CSP-restricted) - ▶ Your HTML -``` - -### `csp` — restrict iframe network access - -```typescript -ui: { - template: MyWidget, - csp: { - connectDomains: ['https://api.example.com'], // fetch / XHR / WebSocket - resourceDomains: ['https://cdn.example.com'], // img / script / style / font - }, -} -``` - -Maps to CSP `connect-src` and `img-src` / `script-src` / `style-src` / `font-src` directives in the widget shell. - -### `contentSecurity` — XSS / sanitization controls - -| Field | Default | Effect when `true` | -| -------------------- | ------- | ----------------------------------------------------------------- | -| `allowUnsafeLinks` | `false` | Allows `javascript:` / `data:` / `vbscript:` URL schemes in links | -| `allowInlineScripts` | `false` | Preserves ` - `, - widgetAccessible: true, -} -``` - -Bridge methods available on `window.FrontMcpBridge`: - -| Method | Returns | Purpose | -| -------------------------------------------- | ------------------------ | ---------------------------------------------------------- | -| `initialize()` | `void` | Auto-called; binds host adapter (OpenAI/Claude/Direct) | -| `getToolInput()` | `unknown` | Input passed to the tool that produced this widget | -| `getToolOutput()` | `unknown` | Raw output from the tool's `execute()` | -| `getStructuredContent()` | `unknown` | Parsed structured output (matches `outputSchema`) | -| `getWidgetState()` / `setWidgetState(state)` | `unknown` / `void` | Read or persist per-widget state | -| `getHostContext()` | `{ theme, displayMode }` | Host-provided rendering context | -| `getTheme()` / `getDisplayMode()` | string | Convenience getters | -| `hasCapability(cap)` | `boolean` | Probe adapter capabilities before calling `callTool` etc. | -| `callTool(name, args)` | `Promise` | Invoke any tool the host allows (needs `widgetAccessible`) | -| `onToolResponseMetadata(cb)` | unsubscribe fn | Subscribe to `ui/html` arrival (inline mode) | - -> **Why not `window.openai.callTool` directly?** The bridge routes to the right host API (OpenAI SDK, Claude postMessage, or FrontMCP direct injection) automatically. Calling `window.openai.*` works on OpenAI Apps SDK but breaks everywhere else. - -## MCP Apps Options - -| Option | Purpose | -| -------------------- | -------------------------------------------------------------------------------- | -| `resourceUri` | Override the auto-generated `ui://widget/{toolName}.html` URI | -| `widgetCapabilities` | Advertise `{ toolListChanged, supportsPartialInput }` at discovery time | -| `prefersBorder` | `_meta.ui.prefersBorder` — host renders a border around the iframe | -| `sandboxDomain` | `_meta.ui.domain` — dedicated origin for additional isolation | -| `invocationStatus` | `{ invoking, invoked }` status strings shown during tool execution | -| `htmlResponsePrefix` | Prefix text for Claude dual-payload mode (default `'Here is the visual result'`) | - -## Rendering Options - -| Option | Default | Effect | -| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `uiType` | `'auto'` | Force renderer: `'html'`, `'react'`, `'mdx'`, `'markdown'`, or `'auto'` (detect) | -| `bundlingMode` | `'static'` | `'static'` = pre-compile shell, inject data at runtime; `'dynamic'` = fresh HTML per call | -| `resourceMode` | `'cdn'` (host-detected — `'inline'` on Claude, #456) | `'cdn'` loads React/MDX/Handlebars from CDN; `'inline'` embeds them (and bundles React inline for `.tsx`/`.jsx` FileSource via #454). Leave unset to host-detect; set explicitly to opt out. | -| `hydrate` | `false` | Enable React hydration after SSR (only when you've verified no mismatches) | -| `customShell` | — | `{ inline?, url?, npm? }` source for a custom HTML shell template | -| `mdxComponents` | — | Components available in MDX templates without imports | - -## Platform Considerations - -| Host | Serving | Bridge adapter | Constraints | -| -------------------- | ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **OpenAI Apps SDK** | `static` or `inline` | `window.openai.*` | Any CDN; uses `_meta['openai/outputTemplate']` | -| **Claude (MCP-UI)** | `inline` (dual-payload) | postMessage | No external script execution in widget iframe by default. For `.tsx` FileSource widgets, set `resourceMode: 'inline'` so React is bundled in (#454). `ui.csp` is now also emitted on the resource (#455) so `connectDomains` / `resourceDomains` take effect. Non-React widgets can also use a self-contained `uiType: 'html'` template. | -| **MCP Inspector** | `static` | Direct | Helpful for local development | -| **Gemini / unknown** | skipped | n/a | `ui` ignored; JSON output is returned | - -## Common Patterns - -| Pattern | Correct | Incorrect | Why | -| ------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Escaping user data | `${ctx.helpers.escapeHtml(input.q)}` | `${input.q}` in raw HTML | Raw interpolation is XSS-prone; helper handles null/non-string | -| Calling tools from widget | `window.FrontMcpBridge.callTool(...)` | `window.openai.callTool(...)` directly | Bridge routes to the right host API; direct calls break cross-host | -| Claude-targeted widgets | `.tsx` FileSource with `resourceMode: 'inline'` (React bundled in) OR self-contained `uiType: 'html'` template | Default `resourceMode: 'cdn'` for Claude (esm.sh import map blocked) | Claude blocks all external script execution; `resourceMode: 'inline'` (#454 fix) inlines React so the widget is self-contained | -| React widgets in Claude | `hydrate: false` | `hydrate: true` | Hydration mismatches throw React error #418 in iframe sandboxes | -| Restricting fetch | Set `csp.connectDomains` to the exact origins the widget calls | Omit `csp` and rely on defaults | Default permits no external connects beyond the shell's own host | -| Widget URI | Let SDK generate `ui://widget/{toolName}.html` (or set `resourceUri`) | Mix non-widget content under `ui://widget/...` | Completion flow special-cases that scheme | - -## Verification Checklist - -### Configuration - -- [ ] Tool has `ui:` set on `@Tool({…})` -- [ ] `template` is one of: function, HTML/MDX string, React component, or `{ file: '...' }` FileSource -- [ ] If template touches user data, it uses `ctx.helpers.escapeHtml(...)` (or leaves default sanitization on) -- [ ] `csp.connectDomains` declares every origin the widget will `fetch` / `WebSocket` to -- [ ] If targeting Claude, `dependencies` overrides only use `cdnjs.cloudflare.com` - -### Runtime - -- [ ] `resources/list` includes `ui://widget/{toolName}.html` (or your `resourceUri`) -- [ ] `resources/read` on that URI returns HTML with the `MCP_APPS_MIME_TYPE` -- [ ] `tools/list` exposes the widget under `_meta['openai/outputTemplate']` -- [ ] In Claude, tool response carries both JSON and `ui/html` blocks (dual-payload) -- [ ] On a non-UI client (e.g. plain stdio inspector), the tool still returns JSON without errors - -## Troubleshooting - -| Problem | Cause | Solution | -| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Widget renders blank in Claude | Default `resourceMode: 'cdn'` blocked by Claude's network policy | Set `resourceMode: 'inline'` and route any `externals` through `cdnjs.cloudflare.com` | -| "Loading widget…" hangs forever in Claude (FileSource) | Default `resourceMode: 'cdn'` emits an esm.sh import map; Claude blocks all external script execution in the widget iframe (#447) | Set `resourceMode: 'inline'` on the `ui` config — `.tsx`/`.jsx` widgets now bundle React inline, no import map, no external module (#454 fix). For non-React widgets, a self-contained `uiType: 'html'` template also works. | -| React error #418 (hydration mismatch) | `hydrate: true` in a host that re-renders inconsistently | Set `hydrate: false` (default) — bridge IIFE handles interactivity | -| `window.FrontMcpBridge.callTool` returns `undefined` | `widgetAccessible: true` not set | Add `widgetAccessible: true` to the `ui` block | -| `_meta.ui.csp` declared on the tool is ignored by Claude | MCP Apps hosts (Claude) only honor CSP declared on the resource content item, not the tool (issue #455) | Use `csp: { connectDomains, resourceDomains }` inside the `ui:` block — the framework now also attaches it to the `resources/read` content's `_meta.ui.csp` (and `_meta['ui/csp']`) so Claude honors it. Fixed in #455. | -| `resources/list` doesn't show the widget URI | `servingMode: 'inline'` only — widget isn't pre-registered | Use `'auto'` or `'static'` if you want a discoverable resource | -| Widget appears on OpenAI but JSON-only on Claude | Claude needs dual-payload mode; happens automatically with `'auto'` | Confirm `servingMode` is `'auto'` (or `'inline'`); set `htmlResponsePrefix` to label the HTML block | -| `.tsx` file path resolves wrong (issue #444) | Relative `template: { file }` resolved against `process.cwd()` | Pass an absolute path — `fileURLToPath(new URL('./widget.tsx', import.meta.url))` from `node:url` — or anchor explicitly. The framework now throws a specific error pointing at this on ENOENT. | - -## Packaging Notes - -- **`@frontmcp/uipack`** — React-free core: shell builder, CSP, bridge IIFE generator, FileSource loader, esm.sh resolver. The `ToolUIConfig` type lives in `@frontmcp/uipack/types` and is re-exported from the SDK. -- **`@frontmcp/ui`** — React-based component library (Card, Button, Badge…) plus runtime renderers (mdx, html, react, pdf, csv, charts, mermaid, flow, math, maps, image, media) and React bridge hooks (`useMcpBridge`, `useCallTool`, `useToolInput`). Install separately if your widgets are React components. - -Add to your `package.json` as needed: - -```bash -yarn add @frontmcp/uipack # core shell + bridge runtime (always available) -yarn add @frontmcp/ui # React components, MUI theme, renderer suite -``` - -## Examples - -| Example | Level | Description | -| ---------------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`basic-html-template`](../examples/create-tool-ui/basic-html-template.md) | Basic | A minimal function template that renders the tool output as a styled HTML card using `ctx.helpers.escapeHtml`. | -| [`widget-with-csp-and-bridge`](../examples/create-tool-ui/widget-with-csp-and-bridge.md) | Intermediate | An interactive widget that fetches from an allow-listed origin via `csp.connectDomains` and invokes another tool via `window.FrontMcpBridge.callTool`. | -| [`file-source-tsx-widget`](../examples/create-tool-ui/file-source-tsx-widget.md) | Advanced | A `.tsx` FileSource widget that bundles a React chart component and renders in every host — including Claude — by setting `resourceMode: 'inline'` so React is inlined into the widget (#454). | - -> See all examples in [`examples/create-tool-ui/`](../examples/create-tool-ui/) - -## Reference - -- [Building Tool UI guide](https://docs.agentfront.dev/frontmcp/guides/building-tool-ui) -- [MCP Apps (SEP-1865)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) -- `ToolUIConfig` type: `@frontmcp/uipack/types` (re-exported from `@frontmcp/sdk` as the `ui?:` option on `@Tool`) -- Related skills: `create-tool`, `create-tool-output-schema-types`, `create-resource` diff --git a/libs/skills/catalog/frontmcp-development/references/create-tool.md b/libs/skills/catalog/frontmcp-development/references/create-tool.md deleted file mode 100644 index 4637ccf7e..000000000 --- a/libs/skills/catalog/frontmcp-development/references/create-tool.md +++ /dev/null @@ -1,887 +0,0 @@ ---- -name: create-tool -description: Build MCP tools with Zod input/output validation and dependency injection ---- - -# 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)` method. The `@Tool` decorator requires at minimum a `name` and an `inputSchema`. Do **not** parameterize `ToolContext` with explicit generics — the input/output types are inferred automatically from the `@Tool` decorator. Hoist the **schemas only** to module scope and derive the `execute()` parameter and return types with `ToolInputOf<>` / `ToolOutputOf<>`, so the schema stays the single source of truth (issue #405). Keep `name`, `description`, `annotations`, `rateLimit`, etc. inside the decorator where they belong. - -```typescript -import { Tool, ToolContext, ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; - -const inputSchema = { - name: z.string().describe('The name of the user to greet'), -}; - -const outputSchema = { - greeting: z.string(), -}; - -type GreetUserInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; -type GreetUserOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; - -@Tool({ - name: 'greet_user', - description: 'Greet a user by name', - inputSchema, - outputSchema, -}) -class GreetUserTool extends ToolContext { - async execute(input: GreetUserInput): Promise { - return { greeting: `Hello, ${input.name}!` }; - } -} -``` - -> **Why derive the types?** Hand-typing `execute(input: { name: string })` next to the schema is a second declaration of the same shape. Change the schema without touching the annotation and TypeScript happily compiles — validation moves to runtime and the IDE never warns. Derived types make the schema the single source of truth: change a Zod field and the `execute()` signature follows automatically. Only the **schemas** are hoisted so they can be re-imported by specs, sibling tools, and generated clients; everything else (`name`, `description`, `annotations`, `rateLimit`, `authProviders`, …) stays inside `@Tool({…})` where the decorator config naturally lives. See [File layout](#file-layout) for sibling-file and folder-per-tool variants. - -### 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.progress(progress, total?, message?)` -- send a progress notification to the client (returns `Promise`) - -**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 (see below) - -**`this.context` properties (FrontMcpContext):** - -| Property | Type | Description | -| -------------- | ------------------- | ----------------------------------- | -| `requestId` | `string` | Unique ID for this request | -| `sessionId` | `string` | Session identifier | -| `scopeId` | `string` | Scope identifier | -| `authInfo` | `Partial` | Authentication info for the request | -| `traceContext` | `TraceContext` | Distributed tracing context | -| `timestamp` | `number` | Request timestamp | -| `metadata` | `RequestMetadata` | Request headers, client IP, etc. | - -## File layout - -Two layouts are endorsed. Pick based on tool count and whether the tool has local helpers, fixtures, or error types. - -**Flat sibling files** — works well for projects with ≤3 tools per app, or when each tool is small enough to fit in one screen: - -```text -src/apps//tools/ -├── get-weather.tool.ts # @Tool class, execute() -├── get-weather.schema.ts # input/output schemas + derived types -└── get-weather.tool.spec.ts # unit tests -``` - -**Folder-per-tool** — recommended for >3 tools per app, or any tool with local helpers, fixtures, or error types: - -```text -src/apps//tools/ -└── get-weather/ - ├── get-weather.tool.ts # @Tool class, execute() - ├── get-weather.schema.ts # input/output schemas + derived types - ├── get-weather.tool.spec.ts # unit tests - ├── index.ts # barrel re-export - └── … # tool-local helpers, fixtures, error types -``` - -Either way the schemas live in their own file so they can be imported from the tool class, the spec, sibling tools, or generated clients without dragging the `@Tool`-decorated class along. Sample `index.ts` for the folder layout: - -```typescript -export { GetWeatherTool } from './get-weather.tool'; -export { - inputSchema as getWeatherInputSchema, - outputSchema as getWeatherOutputSchema, - type GetWeatherInput, - type GetWeatherOutput, -} from './get-weather.schema'; -``` - -## 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** -- `ToolContext` infers the output type from `outputSchema` automatically (no explicit generics needed), giving you compile-time guarantees that `execute()` returns the correct shape. - -```typescript -const inputSchema = { - city: z.string().describe('City name'), -}; - -// Always define outputSchema to validate output and prevent data leaks -const outputSchema = { - temperature: z.number(), - unit: z.enum(['celsius', 'fahrenheit']), - description: z.string(), -}; - -type GetWeatherInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; -type GetWeatherOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; - -@Tool({ - name: 'get_weather', - description: 'Get current weather for a location', - inputSchema, - outputSchema, -}) -class GetWeatherTool extends ToolContext { - async execute(input: GetWeatherInput): Promise { - 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 - -### Derive `execute()` types from the schemas (recommended) - -`ToolContext` already infers the input/output types from the `@Tool` decorator at the **class** level (no generics needed). To make the same types reachable from your `execute()` signature — and from sibling files like specs, helpers, or generated clients — hoist the **schemas only** to module scope and derive types from them with `ToolInputOf<>` / `ToolOutputOf<>` exported from `@frontmcp/sdk`. The decorator config (`name`, `description`, `annotations`, `rateLimit`, …) stays inline: - -```typescript -import { Tool, ToolContext, ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; - -const inputSchema = { - city: z.string().describe('City name'), -}; - -const outputSchema = { - temperature: z.number(), - unit: z.enum(['celsius', 'fahrenheit']), -}; - -type GetWeatherInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; -type GetWeatherOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; - -@Tool({ - name: 'get_weather', - description: 'Get current weather for a location', - inputSchema, - outputSchema, -}) -class GetWeatherTool extends ToolContext { - async execute(input: GetWeatherInput): Promise { - return { temperature: 22, unit: 'celsius' }; - } -} -``` - -**Two equivalent forms** — pick whichever fits the surrounding code; they produce identical types: - -```typescript -// Form 1 — SDK helpers (preferred — works with the type returned by ToolContext) -type GetWeatherInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; -type GetWeatherOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; - -// Form 2 — raw zod (terser if you don't mind a direct z dependency) -type GetWeatherInput = z.infer>; -type GetWeatherOutput = z.infer>; -``` - -> **Never duplicate the shape inline on `execute()` — derive it.** If the schema changes, the type changes automatically. If it doesn't, the compiler tells you exactly which call sites broke. And only hoist the **schemas** — leaving `name`/`description`/`annotations`/throttling inside `@Tool({…})` keeps the decorator declaration self-contained and easy to scan. - -**`return` vs `this.respond()`** — Both work and both are validated against `outputSchema` in the finalize stage: - -```typescript -// Option 1: return (preferred — simpler, same validation) -async execute(input: Input) { - return { temperature: 22, unit: 'celsius' }; -} - -// Option 2: this.respond() — useful for early exit (throws FlowControl.respond internally) -async execute(input: Input) { - if (someCondition) { - this.respond({ temperature: 0, unit: 'celsius' }); // never returns - } - return { temperature: 22, unit: 'celsius' }; -} -``` - -**Early returns from elicitation** must still match the output schema: - -```typescript -async execute(input: Input) { - const result = await this.elicit('Confirm?', { confirm: z.boolean() }); - if (result.action !== 'accept') { - // Must return a value matching outputSchema, not a raw string - return { temperature: 0, unit: 'celsius' as const }; - } - // ... normal execution -} -``` - -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 (use `'resource'` for inline UI / HTML payloads, or attach an interactive widget via the `ui` option — see [Tool UI](#tool-ui-interactive-widgets)) -- **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 - -**Do NOT wrap `execute()` in try/catch.** The framework's tool execution flow automatically catches exceptions, formats error responses, and triggers error hooks. Only use `this.fail(err)` for **business-logic errors** (validation failures, not-found, permission denied). Let infrastructure errors (network, database) propagate naturally. - -```typescript -// WRONG — never do this: -async execute(input) { - try { - const result = await someOperation(); - return result; - } catch (err) { - this.fail(err instanceof Error ? err : new Error(String(err))); - } -} - -// CORRECT — let the framework handle errors: -async execute(input) { - const result = await someOperation(); // errors propagate to framework - return result; -} -``` - -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 { MCP_ERROR_CODES, PublicMcpError, ResourceNotFoundError } 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.progress(progress, total?, message?)` to send progress updates to the client. `this.progress()` returns a `Promise` indicating whether the notification was sent (`false` if no progress token was provided in the request). - -```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.progress(i + 1, input.items.length, `Processing item ${i + 1}`); - 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) - -## Tool UI (Interactive Widgets) - -Attach an HTML widget to a tool's response via the `ui` option. Supported hosts (OpenAI Apps SDK, Claude Artifacts, MCP Inspector) render the widget in a sandboxed iframe alongside the structured JSON output, using the MCP Apps extension ([SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)) and the `ui://widget/{toolName}.html` resource URI scheme. - -**Recommended: keep the widget in its own file** (`./widget.tsx`, `./widget.jsx`, or `./widget.html`) and reference it from the tool via the `FileSource` form (`{ file: ... }`). This separates rendering from `execute()`, lets the widget get its own syntax highlighting / type-check / hot-reload, and avoids a giant template string inside the tool decorator. - -> **`.tsx`/`.jsx` widgets need `@frontmcp/ui` installed (issue #443).** FileSource widgets in those languages are bundled with an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`. Install `@frontmcp/ui` in the consuming project (`npm install @frontmcp/ui` or `yarn add @frontmcp/ui`) at the same version as `@frontmcp/sdk` — without it, server-side bundling fails. `react` / `react-dom` stay external and load from the CDN at runtime; only `@frontmcp/ui` needs to be present on disk. - -> **Anchor the path to the tool file (issue #444).** Relative `file:` paths are resolved against `process.cwd()`, not the tool source's directory. A bare `{ file: './widget.tsx' }` from `src/tools/foo.tool.ts` looks for `/widget.tsx`, not `src/tools/widget.tsx`, and the mismatch only surfaces as `ENOENT` at tool-call time. Use `fileURLToPath(new URL('./widget.tsx', import.meta.url))` from `node:url` (as in the example above) — or pass an absolute path — so the lookup is robust regardless of where the server is launched from. - -> **Name the widget file `*.widget.tsx` so it's excluded from server typecheck (issue #445).** `.tsx`/`.jsx` widgets are bundled separately by uipack/esbuild at render time. The default `tsconfig.json` scaffolded by `frontmcp init` excludes `**/*.widget.tsx` and `**/*.widget.jsx`, so widgets don't force the server tsconfig to set `jsx: 'react-jsx'` or pull in `@types/react`. Running `frontmcp init` on an existing project also adds those excludes. If you want IDE typecheck for the widget source, add a sibling `tsconfig.widget.json` with `jsx: 'react-jsx'` and `include: ['src/**/*.widget.tsx']`. - -```typescript -// src/apps/main/tools/show-ui-card.tool.ts -import { fileURLToPath } from 'node:url'; - -import { Tool, ToolContext, ToolInputOf, z } from '@frontmcp/sdk'; - -const inputSchema = { name: z.string() }; -type ShowUiCardInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; - -// Anchor the widget path to this file (a bare relative path is resolved against -// process.cwd() — issue #444 — so use import.meta.url instead). -const widgetPath = fileURLToPath(new URL('./show-ui-card.widget.tsx', import.meta.url)); - -@Tool({ - name: 'show_ui_card', - description: 'Render a greeting card widget', - inputSchema, - outputSchema: { name: z.string() }, - ui: { - template: { file: widgetPath }, - }, -}) -class ShowUiCardTool extends ToolContext { - async execute(input: ShowUiCardInput) { - return { name: input.name }; - } -} -``` - -```tsx -// src/apps/main/tools/show-ui-card.widget.tsx -type Props = { input: { name: string }; output: { name: string } }; - -export default function ShowUiCardWidget({ output }: Props) { - return
Hello {output.name}
; -} -``` - -For quick prototypes or one-line widgets you can still inline a function/HTML template (`template: (ctx) => '
...
'`) — but graduate to a separate file as soon as the widget needs markup, styles, or state. - -> **TypeScript gotcha for inline function templates (TS7006).** Under `strict` / `noImplicitAny`, `template: (ctx) => …` fails with `Parameter 'ctx' implicitly has an 'any' type` (issue #442). `template` is a union of multiple callables (`TemplateBuilderFn | string | ((props: any) => any) | FileSource`), so TypeScript can't infer a single contextual type for `ctx`. Annotate it explicitly — `import { type TemplateContext } from '@frontmcp/sdk'` and write `template: (ctx: TemplateContext) => …` — or sidestep the issue by using the recommended FileSource form above. - -The `ui` option accepts a `ToolUIConfig` (re-exported from `@frontmcp/uipack/types`). The `template` field supports four formats — auto-detected by the renderer: - -| Format | Shape | When to pick | -| --------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| **FileSource** ⭐ | `{ file: './widget.tsx' }` | **Recommended for anything non-trivial** — `.tsx` / `.jsx` / `.html` source files, transpiled | -| **React component** | `MyWidget` — receives `{ input, output, helpers }` | Importing a React component you already have; set `hydrate: false` (default) for Claude/ChatGPT | -| **HTML / MDX string** | `'
...
'` or `'# Title\n'` | Static markup; pair with `mdxComponents` for MDX | -| **Function** | `(ctx) => string` — receives `input`/`output`/`helpers` | Quick demos / one-liners; pull out to a file once the widget grows | - -Common `ToolUIConfig` fields: - -- `template` -- Required. Function, HTML/MDX string, React component, or FileSource (`{ file }`) -- `widgetDescription` -- Human-readable description surfaced to the host UI -- `servingMode` -- `'auto'` (default) / `'inline'` / `'static'` / `'hybrid'` / `'direct-url'` / `'custom-url'` -- `displayMode` -- `'inline'` (default) / `'fullscreen'` / `'pip'` — host display hint -- `csp` -- `{ connectDomains?, resourceDomains? }` — CSP `connect-src` / `img-src` / `script-src` etc. for the sandboxed iframe -- `contentSecurity` -- `{ allowUnsafeLinks?, allowInlineScripts?, bypassSanitization? }` — XSS / sanitization controls (keep defaults) -- `widgetAccessible` -- `true` to expose `window.FrontMcpBridge.callTool` in the widget -- `resourceUri` -- Override the auto-generated `ui://widget/{toolName}.html` URI -- `uiType` -- `'auto'` (default) / `'html'` / `'react'` / `'mdx'` / `'markdown'` — force a renderer -- `resourceMode` -- `'cdn'` (default) / `'inline'` — use `'inline'` for Claude Artifacts (network-blocked) -- `hydrate` -- `false` (default) — enable client-side React hydration only when host renders deterministically -- `externals`, `dependencies` -- CDN externals for FileSource templates (Claude only allows `cdnjs.cloudflare.com`) -- `customShell`, `invocationStatus`, `widgetCapabilities`, `prefersBorder`, `sandboxDomain`, `htmlResponsePrefix` -- platform-specific knobs - -See [`create-tool-ui`](./create-tool-ui.md) for the full reference (all serving modes, the `window.FrontMcpBridge` API, CSP details, platform considerations, and worked examples). The `outputSchema: 'resource'` media type is a separate path: it returns an MCP resource (link or inline content) as part of the tool's content blocks, leaving rendering up to the host — use it for non-interactive payloads. The `ui` option is what wires a tool into the MCP Apps widget pipeline. - -## 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, z } from '@frontmcp/sdk'; - -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`, `progress`). - -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 { App, FrontMcp } 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; - } -} -``` - -## Auth Providers - -Declare which auth providers a tool requires. Credentials are loaded before tool execution. - -```typescript -// String shorthand — single provider -@Tool({ - name: 'create_issue', - description: 'Create a GitHub issue', - inputSchema: { title: z.string(), body: z.string() }, - authProviders: ['github'], -}) -class CreateIssueTool extends ToolContext { - /* ... */ -} - -// Full mapping — with scopes and required flag -@Tool({ - name: 'deploy_app', - description: 'Deploy to cloud', - inputSchema: { env: z.string() }, - authProviders: [ - { name: 'github', required: true, scopes: ['repo', 'workflow'] }, - { name: 'aws', required: false, alias: 'cloud' }, - ], -}) -class DeployAppTool extends ToolContext { - /* ... */ -} -``` - -Auth provider mapping fields: - -- `name` — Provider name (must match a registered `@AuthProvider`) -- `required?` — Whether credential is required (default: `true`) -- `scopes?` — Required OAuth scopes -- `alias?` — Alias for injection when using multiple providers - -## Environment Availability - -Restrict a tool to specific platforms, runtimes, or environments using `availableWhen`. The tool will be automatically filtered from discovery and blocked from execution when the constraint doesn't match. - -> **Important:** `availableWhen` is a **registry-level** constraint, evaluated at server boot time against the process's runtime context (OS, runtime, deployment mode, NODE_ENV). This is fundamentally different from: -> -> - **Authorization** — per-request, evaluated in HTTP flows against session/user identity -> - **Rule-based filtering** — dynamic, policy-driven, evaluated at request time -> - **`hideFromDiscovery`** — a soft hide from listing; hidden tools can still be called directly -> -> `availableWhen` is a **hard constraint**: filtered tools are excluded from both listing AND execution. Results are logged at boot time for operational visibility. - -```typescript -// macOS-only tool -@Tool({ - name: 'apple_notes_search', - description: 'Search Apple Notes', - inputSchema: { query: z.string() }, - // `os` is the canonical axis since issue #417; `platform` remains as - // a deprecated alias for backward compatibility. - availableWhen: { os: ['darwin'] }, -}) -class AppleNotesSearchTool extends ToolContext { - async execute(input: { query: string }) { - // Only runs on macOS - } -} - -// Node.js production-only tool -@Tool({ - name: 'deploy_service', - description: 'Deploy to production', - inputSchema: { service: z.string() }, - availableWhen: { runtime: ['node'], env: ['production'] }, -}) -class DeployServiceTool extends ToolContext { - async execute(input: { service: string }) { - // Only available in Node.js production - } -} -``` - -Available constraint fields (AND across fields, OR within arrays). Issue #417 added `os` / `provider` / `target` / `surface`: - -- `os` — OS (renamed from `platform`): `'darwin'`, `'linux'`, `'win32'`. `platform` is kept as a deprecated alias. -- `runtime` — JS runtime: `'node'`, `'browser'`, `'edge'`, `'bun'`, `'deno'` -- `deployment` — Coarse mode: `'serverless'`, `'standalone'`, `'distributed'`, `'browser'` -- `provider` — Deploy provider (issue #417): `'bare'`, `'docker'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'netlify'`, `'azure'`, `'gcp'`, `'fly'`, `'render'`, `'railway'`. Override with `FRONTMCP_PROVIDER=`. -- `target` — Build target produced by `frontmcp build --target ` (issue #417): `'cli'`, `'node'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'browser'`, `'sdk'`, `'mcpb'`, `'distributed'`. `'unknown'` in dev. -- `surface` — Per-call axis (issue #417): `'mcp'` (MCP `tools/call`), `'cli'` (CLI subcommand), `'agent'` (in-process dispatch), `'job'` (job runner), `'http-trigger'` (channel HTTP triggers). Use `surface: ['agent']` to block external invocation but allow agent use. -- `env` — NODE_ENV: `'production'`, `'development'`, `'test'` - -When an `availableWhen` constraint fails at call time, FrontMCP throws `EntryUnavailableError`. The error's `data` now carries `missingAxes: string[]` (issue #417) so clients can surface "this tool isn't reachable because provider=vercel / surface=mcp / …" without parsing prose. - -You can also check the platform imperatively inside `execute()`: - -```typescript -if (this.isPlatform('darwin')) { - /* macOS logic */ -} -if (this.isRuntime('node')) { - /* Node.js logic */ -} -if (this.isEnv('production')) { - /* production logic */ -} -``` - -## Elicitation (Interactive Input) - -Tools can request interactive input from users mid-execution using `this.elicit()`. - -> **Prerequisite:** Elicitation must be enabled at server level: -> -> ```typescript -> @FrontMcp({ -> elicitation: { enabled: true }, -> // ... rest of config -> }) -> ``` -> -> See `configure-elicitation` for full configuration options including Redis-backed elicitation stores. -> -> **What happens without it:** Calling `this.elicit()` throws `ElicitationDisabledError` at runtime with the message: _"Elicitation is disabled in server configuration. Enable it via @FrontMcp({ elicitation: { enabled: true } })"_. The tool call fails and the error is returned to the client. There is no compile-time or startup warning — the error only occurs when the tool is actually invoked. - -```typescript -@Tool({ - name: 'confirm_delete', - description: 'Delete a resource after user confirmation', - inputSchema: { resourceId: z.string() }, -}) -class ConfirmDeleteTool extends ToolContext { - async execute(input: { resourceId: string }) { - const result = await this.elicit('Are you sure you want to delete this resource?', { - confirm: z.boolean().describe('Confirm deletion'), - reason: z.string().optional().describe('Reason for deletion'), - }); - - if (result.action === 'accept' && result.data.confirm) { - await this.get(ResourceService).delete(input.resourceId); - return 'Resource deleted'; - } - return 'Deletion cancelled'; - } -} -``` - -## Tool Examples - -Provide usage examples for documentation and discovery: - -```typescript -@Tool({ - name: 'convert_currency', - description: 'Convert between currencies', - inputSchema: { - amount: z.number(), - from: z.string(), - to: z.string(), - }, - examples: [ - { - description: 'Convert USD to EUR', - input: { amount: 100, from: 'USD', to: 'EUR' }, - output: { converted: 85.5, rate: 0.855 }, - }, - { - description: 'Convert with large amount', - input: { amount: 1_000_000, from: 'GBP', to: 'JPY' }, - }, - ], -}) -class ConvertCurrencyTool extends ToolContext { - /* ... */ -} -``` - -## 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 | -| `execute()` types | Derive via `ToolInputOf<{ inputSchema: typeof inputSchema }>` / `ToolOutputOf<{ outputSchema: typeof outputSchema }>` | Inline `execute(input: { city: string })` annotation | Schema is the single source of truth — derive once, use everywhere; no silent drift | -| File layout | Schema in `.schema.ts`, class in `.tool.ts` (sibling files or folder-per-tool) | One large `.tool.ts` that bundles schema + class + helpers | Schema can be imported by specs, sibling tools, generated clients without dragging the class along | -| 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 | -| ToolContext generics | `class MyTool extends ToolContext` | `class MyTool extends ToolContext` | Types are auto-inferred from `@Tool` decorator — explicit generics are redundant | - -## 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 | -| `this.elicit()` throws `ElicitationDisabledError` | Elicitation not enabled at server level | Add `elicitation: { enabled: true }` to `@FrontMcp` config | - -## Examples - -| Example | Level | Description | -| --------------------------------------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`basic-class-tool`](../examples/create-tool/basic-class-tool.md) | Basic | A minimal tool using the class-based pattern with Zod input validation, output schema, and types derived from the schemas. | -| [`tool-with-di-and-errors`](../examples/create-tool/tool-with-di-and-errors.md) | Intermediate | A tool that resolves a database service via DI and uses `this.fail()` for business-logic errors, with `execute()` types derived from the schemas. | -| [`tool-with-rate-limiting-and-progress`](../examples/create-tool/tool-with-rate-limiting-and-progress.md) | Advanced | A batch processing tool that uses rate limiting, concurrency control, progress notifications, and annotations, with `execute()` types derived from the schemas. | - -> See all examples in [`examples/create-tool/`](../examples/create-tool/) - -## Reference - -- [Tools Documentation](https://docs.agentfront.dev/frontmcp/servers/tools) -- Related skills: `create-resource`, `create-prompt`, `configure-throttle`, `create-agent`, `create-tool-ui` diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index 8b202d04f..e4c21b76d 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -1,6 +1,463 @@ { "version": 1, "skills": [ + { + "name": "create-tool", + "category": "development/create", + "description": "ALWAYS use this skill when the user asks to build, modify, or audit a FrontMCP tool. Covers @Tool({...}) end-to-end: class and function-style tools, Zod input/output schemas with derived execute() types, dependency injection, error handling, throttling (rate-limit / concurrency / timeout), auth providers, availability constraints, elicitation, interactive UI widgets (MCP Apps / SEP-1865 — including .tsx FileSource, CSP, window.FrontMcpBridge, host-detect resourceMode), annotations, examples metadata, registration in @App, and per-tool unit testing.", + "path": "create-tool", + "targets": ["all"], + "hasResources": true, + "layout": "component", + "tags": [ + "development", + "tool", + "create-tool", + "decorator", + "input-schema", + "output-schema", + "ToolContext", + "ui", + "mcp-apps", + "annotations", + "throttling", + "auth-providers", + "availability", + "elicitation" + ], + "bundle": ["recommended", "minimal", "full"], + "priority": 10, + "references": [ + { + "name": "quick-start", + "description": "60-second tour — minimal tool, schemas, registration, calling it." + }, + { + "name": "decorator-options", + "description": "Every field on `@Tool({...})` — what it does, default, when to set it." + }, + { + "name": "input-schema", + "description": "Define the tool's input contract — raw Zod shapes, refinements, defaults, optional fields." + }, + { + "name": "output-schema", + "description": "Define the tool's output contract — Zod shape, primitives, media, multi-content arrays." + }, + { + "name": "derived-types", + "description": "Derive execute() parameter and return types from the schemas via ToolInputOf / ToolOutputOf." + }, + { + "name": "execution-context", + "description": "What ToolContext provides at runtime — this.get, this.fetch, this.notify, this.context." + }, + { + "name": "error-handling", + "description": "this.fail, MCP error classes, error flow — when to throw vs fail." + }, + { + "name": "throttling", + "description": "rateLimit, concurrency, timeout — semantics, interaction, defaults." + }, + { + "name": "auth-providers", + "description": "authProviders shorthand vs full mapping, scopes, alias, credential vault basics." + }, + { + "name": "availability", + "description": "availableWhen axes (os / runtime / deployment / provider / target / surface / env), missingAxes, isPlatform." + }, + { + "name": "elicitation", + "description": "this.elicit — request interactive input mid-execution. Server enable + accept/decline/cancel flow." + }, + { + "name": "ui-widgets", + "description": "@Tool({ ui }) — template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec." + }, + { + "name": "annotations", + "description": "readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title — behavioral hints for clients." + }, + { + "name": "function-style-builder", + "description": "tool({...})(handler) — when to pick over a class, register, ctx parameter." + }, + { + "name": "remote-and-esm", + "description": "Tool.esm / Tool.remote — load tools from ESM URLs or remote MCP servers." + }, + { + "name": "registration", + "description": "@App({ tools }) vs @FrontMcp({ tools }), multi-app composition." + }, + { + "name": "file-layout", + "description": "Flat sibling vs folder-per-tool layouts. .schema.ts / .tool.ts / .tool.spec.ts." + }, + { + "name": "testing", + "description": "Per-tool unit tests — @frontmcp/testing, mocking DI, asserting output validation." + } + ], + "examples": [ + { + "name": "01-basic-class-tool", + "level": "basic", + "description": "Minimal class-based tool with Zod input/output schemas and types derived from the schemas. The foundation every other example builds on.", + "tags": ["foundation", "class-tool", "output-schema", "derived-types"], + "features": [ + "Extending `ToolContext` (no generics) and implementing `execute()`", + "Hoisting `inputSchema` / `outputSchema` to a sibling `.schema.ts` file", + "Deriving the `execute()` parameter and return types via `ToolInputOf<>` / `ToolOutputOf<>`", + "Using a Zod raw shape for `inputSchema` (not `z.object(...)`)", + "Always defining `outputSchema` so the tool can't accidentally leak extra fields", + "Registering the tool in an `@App({ tools })`" + ] + }, + { + "name": "02-basic-function-tool", + "level": "basic", + "description": "Function-style `tool({...})(handler)` for a tiny pure-input tool — pick this over a class only when the tool needs no DI / lifecycle / UI.", + "tags": ["foundation", "function-tool", "tool-builder"], + "features": [ + "Using the `tool({...})(handler)` builder for a one-liner", + "Returning a primitive via `outputSchema: 'number'`", + "Registering the function-style tool in `@App({ tools })` exactly like a class tool", + "When function-style is the right choice (and when it isn't)" + ] + }, + { + "name": "03-tool-with-zod-shape-output", + "level": "basic", + "description": "Tool returning structured JSON declared via a Zod raw shape outputSchema — the recommended pattern for any complex output.", + "tags": ["output-schema", "zod-shape", "structured-output"], + "features": [ + "Declaring `outputSchema` as a Zod raw shape `{ field: z.string(), … }`", + "Constraining values with `.int().min(0)` so invalid output is rejected at the boundary", + "Letting unrelated fields returned by the implementation (e.g. an upstream API's extras) be stripped silently", + "Deriving `OrderSummaryOutput` once so the type and runtime contract can't drift" + ] + }, + { + "name": "04-tool-with-zod-schema-output", + "level": "advanced", + "description": "Tool returning a discriminated union via a full `z.discriminatedUnion(...)` outputSchema — for outputs that branch on a kind field.", + "tags": ["output-schema", "zod-schema", "discriminated-union"], + "features": [ + "Using a full Zod schema (`z.discriminatedUnion(...)`) as `outputSchema` instead of a raw shape", + "Branching the runtime output on a discriminant `kind` literal", + "Letting TypeScript narrow the return type per branch (via `as const` on the discriminant)", + "Knowing when full Zod schemas are the right pick (unions, transforms) and when a raw shape is enough" + ] + }, + { + "name": "05-tool-with-primitive-output", + "level": "basic", + "description": "Tool returning a single primitive — `outputSchema: 'string' | 'number' | 'boolean' | 'date'` for single-value outputs.", + "tags": ["output-schema", "primitive-output"], + "features": [ + "Using a primitive literal (`'string'`, `'number'`, `'boolean'`, `'date'`) for `outputSchema`", + "Returning the bare value directly from `execute()` instead of wrapping it", + "Picking primitive literals over a one-field Zod shape for ergonomic clarity", + "Four concrete tools in one file (`fmt_currency`, `add`, `is_palindrome`, `now`) demonstrating each primitive form" + ] + }, + { + "name": "06-tool-with-media-output", + "level": "intermediate", + "description": "Tool returning binary content (image / audio) or a multi-content array of `[text, image]` — for outputs that aren't plain JSON.", + "tags": ["output-schema", "media-output", "image", "multi-content"], + "features": [ + "Returning a base64-encoded image with `outputSchema: 'image'` and `{ data, mimeType }`", + "Returning audio with `outputSchema: 'audio'` (same `{ data, mimeType }` shape, audio MIME types)", + "Returning multi-content via `outputSchema: ['string', 'image']` — text summary + annotated image in one response", + "When to pick a media literal vs `'resource_link'` (host-fetched URI)" + ] + }, + { + "name": "08-tool-with-provider-injection", + "level": "intermediate", + "description": "Tool that resolves a DI-registered service via `this.get(TOKEN)` and uses it to power `execute()` — the standard pattern for tools that talk to a database or external API.", + "tags": ["di", "provider", "this.get", "error-handling"], + "features": [ + "Defining a typed DI token with `Symbol('UserService')` and `Token`", + "Implementing a `@Provider` and registering it in the same `@App` as the tool", + "Resolving the service inside `execute()` via `this.get(USER_SERVICE)` (throws when missing)", + "Translating \"not found\" into `ResourceNotFoundError` via `this.fail(...)` so the client gets a proper MCP error code (-32002)" + ] + }, + { + "name": "09-tool-with-multiple-providers", + "level": "intermediate", + "description": "Tool composing three DI services — config (env-only), cache (optional, `tryGet`), and database (required) — the realistic shape for a production tool.", + "tags": ["di", "multiple-providers", "cache-aside", "tryGet"], + "features": [ + "Resolving multiple providers via `this.get(TOKEN)` and `this.tryGet(TOKEN)`", + "Cache-aside pattern — check `tryGet(CACHE)` first, fall back to the database", + "Reading typed config from a `CONFIG` token vs `process.env` directly", + "Letting the tool work in production (with cache) AND in test (without it)" + ] + }, + { + "name": "11-tool-with-fetch", + "level": "intermediate", + "description": "Tool calling an external HTTP API with `this.fetch` — context propagation, status-code handling, and timing out via the abort signal.", + "tags": ["fetch", "http", "external-api", "error-handling"], + "features": [ + "Using `this.fetch(url, init?)` so trace context propagates to the upstream service", + "Translating non-2xx HTTP responses into `PublicMcpError` so the MCP client gets a clean error", + "Passing `this.context.abortSignal` to the fetch so a tool `timeout` cancels in-flight HTTP work", + "Letting genuine network errors (DNS failure, ECONNREFUSED) propagate to the framework's error flow" + ] + }, + { + "name": "12-tool-with-fetch-and-retries", + "level": "advanced", + "description": "Tool calling a flaky external API with exponential backoff retries, an Idempotency-Key for safety, and respect for `Retry-After` on 429s.", + "tags": ["fetch", "retries", "exponential-backoff", "idempotency-key", "429-rate-limit"], + "features": [ + "Retrying on transient errors (5xx + 429) with exponential backoff plus jitter", + "Generating an `Idempotency-Key` so retried POSTs don't duplicate side effects on the upstream", + "Respecting an upstream `Retry-After` header on 429 responses instead of guessing", + "Capping the total retry budget with `timeout: { executeMs }` so a wedged upstream can't hang the call indefinitely" + ] + }, + { + "name": "13-tool-with-single-auth-provider", + "level": "intermediate", + "description": "Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand — credentials loaded before `execute()` runs.", + "tags": ["auth-providers", "oauth", "github", "this.authProviders"], + "features": [ + "Declaring a single required OAuth provider with the `authProviders: ['github']` shorthand", + "Reading pre-formatted credentials via `await this.authProviders.headers('github')`", + "Letting the framework reject unauthenticated calls before `execute()` runs (no auth-check boilerplate)", + "Trusting the framework to handle token refresh, expiration, and the OAuth start URL" + ] + }, + { + "name": "14-tool-with-multiple-auth-providers", + "level": "advanced", + "description": "Tool with the full `authProviders` mapping form — one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing.", + "tags": ["auth-providers", "oauth", "scopes", "optional-auth", "this.authProviders.tryHeaders"], + "features": [ + "Using the object form of `authProviders` to set `required`, `scopes`, and `alias`", + "Requesting specific OAuth scopes so the framework triggers incremental auth when missing", + "Resolving an optional provider via `await this.authProviders.tryHeaders('cloud')` (returns `null` when absent)", + "Branching the tool's behavior — full deploy when both providers are present; preview-only when the cloud provider is missing" + ] + }, + { + "name": "15-tool-with-credential-vault", + "level": "advanced", + "description": "Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault — the pattern for credentials that aren't OAuth.", + "tags": ["auth-providers", "credential-vault", "slack-webhook", "encryption-at-rest"], + "features": [ + "Declaring a vault-backed auth provider with `authProviders: ['slack-webhook']`", + "Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` — same API as OAuth", + "Letting the framework handle per-session AES-256-GCM encryption at rest (Redis or memory store)", + "Knowing when to pick the vault (static secrets the user knows) vs OAuth (delegated identity)" + ] + }, + { + "name": "16-tool-with-rate-limit", + "level": "intermediate", + "description": "Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute — the protection for expensive / external-API-billed operations.", + "tags": ["throttling", "rate-limit", "abuse-protection"], + "features": [ + "Capping the tool to N invocations per windowMs (per-session by default)", + "Letting the framework reject over-limit calls with `RateLimitError` (code `'RATE_LIMIT_EXCEEDED'`, HTTP status 429) and a retry-after hint clients can back off against", + "Combining `rateLimit` with `annotations.openWorldHint: true` so clients know the tool talks to billed external services", + "Sizing the limit against upstream quota / billing — not just \"what feels reasonable\"" + ] + }, + { + "name": "17-tool-with-concurrency-and-timeout", + "level": "advanced", + "description": "Tool with `concurrency` + `timeout` for a real bottleneck (PDF rendering) — caps simultaneous in-flight work AND hard-caps per-call duration.", + "tags": ["throttling", "concurrency", "timeout", "abort-signal"], + "features": [ + "Capping simultaneous in-flight executions with `concurrency: { maxConcurrent }` (server-wide by default)", + "Hard-bounding any single call with `timeout: { executeMs }` so a wedged invocation can't hold a concurrency slot indefinitely", + "Propagating `this.context.abortSignal` to in-flight work so the timeout actually cancels it", + "Combining `rateLimit` + `concurrency` + `timeout` as a production triple" + ] + }, + { + "name": "18-tool-with-progress-and-notify", + "level": "intermediate", + "description": "Long-running tool emitting progress updates (`this.progress`), log notifications (`this.notify`), and stage markers (`this.mark`) — the standard pattern for jobs you don't want to feel hung.", + "tags": ["progress", "notifications", "mark", "long-running"], + "features": [ + "Emitting per-item progress with `await this.progress(current, total, message)`", + "Sending free-form log notifications at `info` / `warning` / `error` levels with `await this.notify(message, level)`", + "Marking execution stages with `this.mark(stage)` so observability tools have breadcrumbs", + "Letting `this.progress(...)` return `false` cheaply when no progress token was provided (zero-cost when nobody's listening)" + ] + }, + { + "name": "19-tool-with-elicitation", + "level": "advanced", + "description": "Tool that pauses mid-execution to ask the user for confirmation + extra input via `this.elicit(...)` — the safe pattern for destructive or expensive actions.", + "tags": ["elicitation", "this.elicit", "destructive-action", "confirmation"], + "features": [ + "Calling `this.elicit(message, { fieldSchema })` to request interactive input mid-`execute()`", + "Branching on `result.action` — `accept` / `decline` / `cancel` — and matching the early returns against `outputSchema`", + "Pairing elicitation with `annotations.destructiveHint: true` so clients know to render the confirmation prominently", + "Requiring `elicitation: { enabled: true }` at the `@FrontMcp({...})` server level — and what fails when it isn't" + ] + }, + { + "name": "20-tool-with-annotations", + "level": "basic", + "description": "Four tools showing the standard annotation combinations — read-only query, destructive delete, send-email side-effecting, external-API search — and the client behavior each combination opts into.", + "tags": ["annotations", "readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"], + "features": [ + "Setting `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` to opt into specific client behaviors (auto-retry, confirmation gating, parallelization)", + "Providing a human-readable `title` that overrides the snake_case `name` in client UIs", + "Picking the conservative defaults when the annotations aren't obvious (omitting fields is safer than guessing)", + "Why `send_email` sets `idempotentHint: false` (each call sends a new email) while `delete_user` sets it to `true` (deleting twice still leaves the user deleted)" + ] + }, + { + "name": "21-tool-with-availability-constraints", + "level": "advanced", + "description": "Three tools showing the `availableWhen` axes — macOS-only OS gate, production+Node runtime gate, and a `surface` gate that allows agent + job invocation but blocks direct MCP-client calls.", + "tags": ["availableWhen", "os", "runtime", "surface", "EntryUnavailableError"], + "features": [ + "Restricting a tool to macOS with `availableWhen: { os: ['darwin'] }`", + "Composing constraints — `runtime: ['node']` AND `env: ['production']` — both must match for the tool to be available", + "Using the `surface` axis to expose an internal tool to agents and jobs while hiding it from direct user invocation", + "Knowing what happens on mismatch — `EntryUnavailableError` (`-32099`) with `data.missingAxes` so clients show the right \"not available here\" reason" + ] + }, + { + "name": "22-tool-with-ui-html-template", + "level": "intermediate", + "description": "Tool with an inline HTML function template — `ui: { template: (ctx) => '
' }` — for a quick widget that doesn't need a separate `.tsx` file.", + "tags": ["ui", "ui-widgets", "html-template", "escapeHtml", "TemplateContext"], + "features": [ + "Adding a `ui:` block with a function template `(ctx: TemplateContext) => string`", + "Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type", + "Always escaping user-controlled output with `ctx.helpers.escapeHtml(...)` so the widget can't XSS itself", + "Reading from `ctx.output` and `ctx.helpers` — the typed runtime context the template renderer hands you" + ] + }, + { + "name": "23-tool-with-ui-filesource-tsx", + "level": "advanced", + "description": "Tool with a `.tsx` widget in a separate file via the `FileSource` form — the recommended pattern for any React widget. Path anchored with `import.meta.url` so it survives any cwd.", + "tags": ["ui", "ui-widgets", "FileSource", "tsx", "import.meta.url", "host-detect"], + "features": [ + "Pointing `template` at a sibling `.tsx` file via the `FileSource` form `{ file: ... }`", + "Anchoring the path to the tool source with `fileURLToPath(new URL('./...widget.tsx', import.meta.url))` so `process.cwd()` doesn't matter", + "Leaving `resourceMode` unset — the framework host-detects (`'inline'` for Claude, `'cdn'` for others)", + "Naming the widget `*.widget.tsx` so the scaffolded `tsconfig.json`'s `exclude` keeps it out of the server typecheck" + ] + }, + { + "name": "24-tool-with-ui-csp-and-bridge", + "level": "advanced", + "description": "Interactive tool widget that fetches from an allow-listed CSP origin and invokes another tool via `window.FrontMcpBridge.callTool` — the full pattern for live-data widgets that need cross-tool composition.", + "tags": ["ui", "csp", "widgetAccessible", "FrontMcpBridge", "interactive-widget"], + "features": [ + "Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)", + "Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs", + "Embedding initial data into the widget's inline ``)", + "Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback" + ] + }, + { + "name": "25-tool-handing-off-to-job", + "level": "advanced", + "description": "Thin tool that validates input and enqueues a `@Job` to do the heavy lifting — the right pattern for any operation that takes more than a few seconds.", + "tags": ["composition", "jobs", "job-handoff", "hideFromDiscovery"], + "features": [ + "Splitting a long-running operation into a thin tool (validates, enqueues, returns a tracking handle) plus a `@Job` (does the work)", + "Returning the job ID + status URL from the tool so the client can poll or stream updates", + "Using `availableWhen: { surface: ['mcp', 'agent'] }` on the tool while leaving the heavy `@Job` invisible to direct invocation", + "Why this beats running the heavy work inside `execute()` (avoids tool-call timeout limits, lets the job retry independently)" + ] + }, + { + "name": "26-tool-with-resource-link-output", + "level": "advanced", + "description": "Tool returning `outputSchema: 'resource_link'` — the URI is sent to the client; the client fetches the body via `resources/read`. The right pattern for large or cacheable payloads.", + "tags": ["output-schema", "resource_link", "large-payload", "caching"], + "features": [ + "Returning `outputSchema: 'resource_link'` from a tool — `{ uri }` only, body fetched separately", + "Pairing the tool with a matching `@Resource({ uri: 'export://{exportId}.csv' })` URI template that resolves to the actual body", + "When `'resource_link'` beats `'image'` / `'audio'` / a raw byte response (large payloads, cacheable URIs, deferred fetch)", + "Cross-linking to the `create-resource` skill for the URI-template resource on the other end" + ] + }, + { + "name": "27-tool-with-examples-metadata", + "level": "basic", + "description": "Tool with the `examples: [...]` field on `@Tool({...})` — concrete input (and optional expected output) examples surfaced in `tools/list` so AI clients can render them as quick-action suggestions.", + "tags": ["examples-metadata", "discovery", "tools-list"], + "features": [ + "Adding `examples: [{ description, input, output? }]` to `@Tool({...})` so AI clients see canned invocations", + "Writing realistic example inputs so the description in `tools/list` is concrete, not abstract", + "Including `output?` for examples where showing the expected result helps client UX (preview tiles, etc.)", + "Why `examples` are advisory metadata — never relied on by the framework, only surfaced to discovery" + ] + } + ], + "rules": [ + { + "name": "always-define-output-schema", + "constraint": "Every `@Tool` defines `outputSchema`.", + "severity": "required" + }, + { + "name": "derive-execute-types", + "constraint": "`execute()` parameter and return types come from `ToolInputOf<>` / `ToolOutputOf<>` — never duplicated inline.", + "severity": "required" + }, + { + "name": "input-schema-is-raw-shape", + "constraint": "`inputSchema` is a raw Zod shape, never `z.object(...)`.", + "severity": "required" + }, + { + "name": "no-toolcontext-generics", + "constraint": "`class MyTool extends ToolContext` — never `extends ToolContext`.", + "severity": "required" + }, + { + "name": "no-try-catch-around-execute", + "constraint": "Do not wrap the body of `execute()` in `try/catch`. The framework owns the error flow.", + "severity": "required" + }, + { + "name": "register-in-app", + "constraint": "Register tools in `@App({ tools })`, not directly on `@FrontMcp({ tools })` (the latter is the simple-server escape hatch).", + "severity": "recommended" + }, + { + "name": "snake-case-tool-names", + "constraint": "Tool `name:` field is always `snake_case` (e.g. `get_weather`, not `getWeather`).", + "severity": "required" + }, + { + "name": "use-this-fail-for-business-errors", + "constraint": "`this.fail(new SomeMcpError(...))` for business-logic errors — never raw `throw new Error(...)`.", + "severity": "required" + }, + { + "name": "widget-paths-anchor-with-import-meta-url", + "constraint": "`.tsx` widget paths in `ui.template: { file }` are anchored via `fileURLToPath(new URL(...))`, never bare relative.", + "severity": "required" + }, + { + "name": "widget-resource-mode-host-detect", + "constraint": "Leave `ui.resourceMode` unset — the framework host-detects (`inline` for Claude, `cdn` for others).", + "severity": "recommended" + } + ] + }, { "name": "frontmcp-config", "category": "config", @@ -1322,186 +1779,6 @@ } ] }, - { - "name": "create-tool-annotations", - "description": "Reference for MCP tool annotation hints like readOnly, destructive, and idempotent", - "examples": [ - { - "name": "destructive-delete-tool", - "description": "Demonstrates annotating a tool that deletes data, enabling MCP clients to warn users before execution.", - "level": "intermediate", - "tags": ["development", "elicitation", "tool", "annotations", "destructive", "delete"], - "features": [ - "Setting `destructiveHint: true` on the delete tool so MCP clients can trigger confirmation warnings", - "Setting `idempotentHint: true` on the delete tool because deleting the same user twice produces the same outcome", - "Setting `openWorldHint: true` on the email tool because it interacts with an external SMTP service", - "Setting `idempotentHint: false` on the email tool because each call sends a new email", - "How different annotation combinations express different behavioral contracts" - ] - }, - { - "name": "readonly-query-tool", - "description": "Demonstrates annotating a tool that only reads data, signaling to MCP clients that it has no side effects and is safe to retry.", - "level": "basic", - "tags": ["development", "database", "local", "tool", "annotations", "readonly"], - "features": [ - "Setting `readOnlyHint: true` to indicate the tool performs no mutations", - "Setting `destructiveHint: false` to tell clients no data will be deleted or overwritten", - "Setting `idempotentHint: true` because repeated calls with the same input produce the same result", - "Setting `openWorldHint: false` because the tool only accesses local database data", - "Using `title` to provide a human-friendly display name for MCP client UIs" - ] - } - ] - }, - { - "name": "create-tool-output-schema-types", - "description": "Reference for all supported outputSchema types including Zod shapes and JSON Schema", - "examples": [ - { - "name": "primitive-and-media-outputs", - "description": "Demonstrates using primitive string literals and media types as `outputSchema` for tools that return plain text, images, or multi-content arrays.", - "level": "intermediate", - "tags": ["development", "output-schema", "tool", "output", "schema", "types"], - "features": [ - "Using `'string'` literal to return plain text output", - "Using `'image'` literal to return base64 image data", - "Using `['string', 'image']` array to return multi-content (text plus image) in a single response", - "Other available primitives: `'number'`, `'boolean'`, `'date'`", - "Other available media types: `'audio'`, `'resource'`, `'resource_link'`" - ] - }, - { - "name": "zod-raw-shape-output", - "description": "Demonstrates the recommended approach of using a Zod raw shape as `outputSchema` for structured, validated JSON output.", - "level": "basic", - "tags": ["development", "codecall", "output-schema", "tool", "output", "schema"], - "features": [ - "Using a Zod raw shape (plain object with Zod types) as `outputSchema` for structured output", - "The output is validated at runtime against the schema before being returned to the client", - "This is the recommended pattern for CodeCall compatibility and data leak prevention", - "The `execute()` return type is automatically inferred from the output schema" - ] - }, - { - "name": "zod-schema-advanced-output", - "description": "Demonstrates using full Zod schema objects (not raw shapes) as `outputSchema`, including `z.object()`, `z.array()`, `z.union()`, and `z.discriminatedUnion()`.", - "level": "advanced", - "tags": ["development", "output-schema", "tool", "output", "schema", "types"], - "features": [ - "Using `z.object()` for structured output with nested arrays and nullable fields", - "Using `z.discriminatedUnion()` to return different output shapes based on a discriminant field", - "Full Zod schemas provide the same validation as raw shapes but support more complex types", - "Output is validated at runtime -- mismatched return values trigger validation errors" - ] - } - ] - }, - { - "name": "create-tool-ui", - "description": "Render an interactive UI widget for a tool's result via @Tool({ ui }), MCP Apps (SEP-1865), and the ui:// resource scheme", - "examples": [ - { - "name": "basic-html-template", - "description": "A minimal function template that renders the tool output as a styled HTML card using `ctx.helpers.escapeHtml`.", - "level": "basic", - "tags": ["development", "tool", "ui", "widget", "mcp-apps", "html", "basic"], - "features": [ - "Adding `ui:` to `@Tool({...})` with a function template `(ctx) => string`", - "Reading `ctx.input`, `ctx.output`, and `ctx.helpers` from the typed `TemplateContext`", - "Escaping user-controlled strings via `ctx.helpers.escapeHtml(...)`", - "Letting `servingMode` default to `auto` so the SDK picks the right mode per host (OpenAI vs Claude vs no-UI)", - "Surfacing `widgetDescription` for the host UI" - ] - }, - { - "name": "widget-with-csp-and-bridge", - "description": "An interactive widget that fetches from an allow-listed origin via `csp.connectDomains` and invokes another tool via `window.FrontMcpBridge.callTool`.", - "level": "intermediate", - "tags": ["development", "tool", "ui", "widget", "csp", "bridge", "interactive", "mcp-apps"], - "features": [ - "Restricting widget network access with `csp.connectDomains` (CSP `connect-src`)", - "Enabling tool invocation from the widget via `widgetAccessible: true`", - "Calling another tool via `window.FrontMcpBridge.callTool(name, args)` instead of direct host APIs", - "Using `ctx.helpers.jsonEmbed(...)` to safely pass JSON into an inline `