From f3c5b8277b8f7fe1da9f596d573343a4046e6760 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 28 May 2026 23:27:10 +0300 Subject: [PATCH 01/17] feat(skills): add create-tool-ui reference for the MCP Apps widget surface (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a comprehensive `create-tool-ui` reference under `frontmcp-development` documenting `@Tool({ ui })` — the entry point for the MCP UI / MCP Apps feature (SEP-1865). Adds three worked examples (basic-html-template, widget-with-csp-and-bridge, file-source-tsx-widget), wires the reference into the routing table, reading order, manifest, and related-skills list. Also corrects long-standing API drift in `docs/frontmcp/guides/building-tool-ui.mdx` (`servingMode`, `displayMode`, template helpers) and replaces the broken Platform Detection section. Closes #440 --- docs/frontmcp/guides/building-tool-ui.mdx | 57 +-- .../catalog/frontmcp-development/SKILL.md | 52 ++- .../create-tool-ui/basic-html-template.md | 106 +++++ .../create-tool-ui/file-source-tsx-widget.md | 155 +++++++ .../widget-with-csp-and-bridge.md | 173 ++++++++ .../references/create-tool-ui.md | 388 ++++++++++++++++++ libs/skills/catalog/skills-manifest.json | 56 +++ 7 files changed, 939 insertions(+), 48 deletions(-) create mode 100644 libs/skills/catalog/frontmcp-development/examples/create-tool-ui/basic-html-template.md create mode 100644 libs/skills/catalog/frontmcp-development/examples/create-tool-ui/file-source-tsx-widget.md create mode 100644 libs/skills/catalog/frontmcp-development/examples/create-tool-ui/widget-with-csp-and-bridge.md create mode 100644 libs/skills/catalog/frontmcp-development/references/create-tool-ui.md diff --git a/docs/frontmcp/guides/building-tool-ui.mdx b/docs/frontmcp/guides/building-tool-ui.mdx index 75ff3eca..df2fe32d 100644 --- a/docs/frontmcp/guides/building-tool-ui.mdx +++ b/docs/frontmcp/guides/building-tool-ui.mdx @@ -136,17 +136,21 @@ export default class GetWeatherTool extends ToolContext { Human-readable description of what the widget displays. Shown to users in UI-capable hosts. - - How the widget should be displayed: - - `inline` - Rendered directly in the conversation - - `modal` - Opens in a modal dialog - - `panel` - Shows in a side panel + + Preferred display mode (hint to the host — may be ignored): + - `inline` - Rendered inline in the conversation (default) + - `fullscreen` - Request fullscreen display + - `pip` - Picture-in-picture - - How the HTML is served: - - `static` - HTML string is returned directly - - `iframe` - Content is served via iframe URL + + How the HTML is delivered to the client (default: `auto`): + - `auto` - Auto-select per host (OpenAI / Claude / unknown) + - `inline` - Embedded in tool response `_meta['ui/html']` (works everywhere) + - `static` - Pre-compiled at startup; client fetches `ui://widget/{toolName}.html` via `resources/read` + - `hybrid` - Shell pre-compiled; component code + data delivered per call in `_meta['ui/component']` + - `direct-url` - Served from an HTTP path on the MCP server (`directPath`) + - `custom-url` - Served from an external URL (`customWidgetUrl`, supports a `{token}` placeholder) @@ -247,20 +251,22 @@ template: (ctx) => { const { temperature, conditions } = ctx.output; // Helper functions - const { escapeHtml, formatDate, formatNumber } = ctx.helpers; + const { escapeHtml, formatDate, formatCurrency, uniqueId, jsonEmbed } = ctx.helpers; // Always escape user-provided strings! - return `

${helpers.escapeHtml(location)}

`; + return `

${escapeHtml(location)}

`; } ``` ### Available Helpers -| Helper | Description | -| ---------------------------- | ----------------------------------- | -| `escapeHtml(str)` | Escape HTML entities to prevent XSS | -| `formatDate(date)` | Format a date string | -| `formatNumber(num, options)` | Format numbers with locale support | +| Helper | Description | +| --------------------------------- | -------------------------------------------------------------------- | +| `escapeHtml(str)` | Escape HTML entities to prevent XSS (handles `null`/`undefined`) | +| `formatDate(date, format?)` | Format a date (accepts `Date` or ISO string) | +| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting (defaults to `'USD'`) | +| `uniqueId(prefix?)` | Generate a unique ID for DOM elements | +| `jsonEmbed(data)` | Safely embed JSON in an inline ``) | Always use `helpers.escapeHtml()` when rendering user-provided data to prevent XSS vulnerabilities. @@ -305,7 +311,7 @@ import { z } from '@frontmcp/sdk'; ); const details = descriptionList([ - { term: 'Total', description: helpers.formatNumber(output.totalExpenses, { style: 'currency', currency: 'USD' }) }, + { term: 'Total', description: helpers.formatCurrency(output.totalExpenses, 'USD') }, { term: 'Pending', description: String(output.pendingCount) }, { term: 'Approved', description: String(output.approvedCount) }, ], { layout: 'grid' }); @@ -339,19 +345,16 @@ export default class ExpenseSummaryTool extends ToolContext { --- -## Platform Detection +## Platform Considerations -Different platforms (OpenAI, Claude, browsers) have different capabilities. Use theme utilities to adapt: +Different MCP hosts have different capabilities and network policies: -```typescript -import { createTheme, canUseCdn, needsInlineScripts } from '@frontmcp/uipack/theme'; +- **OpenAI Apps SDK** — any CDN reachable; widget URI surfaces under `_meta['openai/outputTemplate']` +- **Claude (MCP-UI)** — **only `cdnjs.cloudflare.com` is reachable**; prefer `resourceMode: 'inline'` so the widget shell is self-contained, and pin any `externals` to cdnjs URLs via `dependencies` +- **MCP Inspector** — useful for local development; honors `servingMode: 'static'` +- **Gemini / unknown hosts** — `ui` is ignored; the tool returns JSON only -// Claude Artifacts blocks external requests -// Use inline scripts when needed -if (needsInlineScripts(platform)) { - // Inline Tailwind CSS -} -``` +The default `servingMode: 'auto'` selects the right mode per host. See the [`create-tool-ui` skill](https://docs.agentfront.dev/frontmcp/skills/create-tool-ui) for the full reference: serving modes, CSP, the `window.FrontMcpBridge` runtime, file-based `.tsx` widgets, and platform-specific troubleshooting. --- diff --git a/libs/skills/catalog/frontmcp-development/SKILL.md b/libs/skills/catalog/frontmcp-development/SKILL.md index b1e1ce29..427ab7d3 100644 --- a/libs/skills/catalog/frontmcp-development/SKILL.md +++ b/libs/skills/catalog/frontmcp-development/SKILL.md @@ -55,26 +55,27 @@ This is a router skill. The "steps" here are how to choose the right reference ( ## Scenario Routing Table -| Scenario | Reference | Description | -| -------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------- | -| Expose an executable action that AI clients can call | `create-tool` | Class-based or function-style tools with Zod input/output validation | -| Expose read-only data via a URI | `create-resource` | Static resources or URI template resources for dynamic data | -| Create a reusable conversation template or system prompt | `create-prompt` | Prompt entries with arguments and multi-turn message sequences | -| Build an autonomous AI loop that orchestrates tools | `create-agent` | Agent entries with LLM config, inner tools, and swarm handoff | -| Register shared services or configuration via DI | `create-provider` | Dependency injection tokens, lifecycle hooks, factory providers | -| Run a background task with progress and retries | `create-job` | Job entries with attempt tracking, retry config, and progress | -| Chain multiple jobs into a sequential pipeline | `create-workflow` | Workflow entries that compose jobs with data passing | -| Write instruction-only AI guidance (no code execution) | `create-skill` | Skill entries with markdown instructions from files, strings, or URLs | -| Write AI guidance that also orchestrates tools | `create-skill-with-tools` | Skill entries that combine instructions with registered tools | -| Look up any decorator signature or option | `decorators-guide` | Complete reference for @Tool, @Resource, @Prompt, @Agent, @App, @FrontMcp, and more | -| Overview of all official adapters | `official-adapters` | Router to all adapter types; adapter vs plugin comparison | -| Integrate an external API via OpenAPI spec | `openapi-adapter` | OpenapiAdapter with auth, polling, filtering, transforms, format resolution, $ref security | -| Use official plugins (caching, remember, feature flags) | `official-plugins` | Built-in plugins for caching, session memory, approval, and feature flags (dashboard is beta) | -| Connect to an external data source via a custom adapter | `create-adapter` | Create custom adapters for external data sources | -| Configure LLM settings for an agent component | `create-agent-llm-config` | Configure LLM settings for agent components | -| Add will/did/around lifecycle hooks to a plugin | `create-plugin-hooks` | Add lifecycle hooks to plugins (will/did/around) | -| Annotate tools with client hints for AI clients | `create-tool-annotations` | Add MCP tool annotations for client hints | -| Define typed output schemas for tool responses | `create-tool-output-schema-types` | Define typed output schemas for tools | +| Scenario | Reference | Description | +| -------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Expose an executable action that AI clients can call | `create-tool` | Class-based or function-style tools with Zod input/output validation | +| Expose read-only data via a URI | `create-resource` | Static resources or URI template resources for dynamic data | +| Create a reusable conversation template or system prompt | `create-prompt` | Prompt entries with arguments and multi-turn message sequences | +| Build an autonomous AI loop that orchestrates tools | `create-agent` | Agent entries with LLM config, inner tools, and swarm handoff | +| Register shared services or configuration via DI | `create-provider` | Dependency injection tokens, lifecycle hooks, factory providers | +| Run a background task with progress and retries | `create-job` | Job entries with attempt tracking, retry config, and progress | +| Chain multiple jobs into a sequential pipeline | `create-workflow` | Workflow entries that compose jobs with data passing | +| Write instruction-only AI guidance (no code execution) | `create-skill` | Skill entries with markdown instructions from files, strings, or URLs | +| Write AI guidance that also orchestrates tools | `create-skill-with-tools` | Skill entries that combine instructions with registered tools | +| Look up any decorator signature or option | `decorators-guide` | Complete reference for @Tool, @Resource, @Prompt, @Agent, @App, @FrontMcp, and more | +| Overview of all official adapters | `official-adapters` | Router to all adapter types; adapter vs plugin comparison | +| Integrate an external API via OpenAPI spec | `openapi-adapter` | OpenapiAdapter with auth, polling, filtering, transforms, format resolution, $ref security | +| Use official plugins (caching, remember, feature flags) | `official-plugins` | Built-in plugins for caching, session memory, approval, and feature flags (dashboard is beta) | +| Connect to an external data source via a custom adapter | `create-adapter` | Create custom adapters for external data sources | +| Configure LLM settings for an agent component | `create-agent-llm-config` | Configure LLM settings for agent components | +| Add will/did/around lifecycle hooks to a plugin | `create-plugin-hooks` | Add lifecycle hooks to plugins (will/did/around) | +| Annotate tools with client hints for AI clients | `create-tool-annotations` | Add MCP tool annotations for client hints | +| Define typed output schemas for tool responses | `create-tool-output-schema-types` | Define typed output schemas for tools | +| Render an interactive UI widget for a tool's result | `create-tool-ui` | Configure `@Tool({ ui })` widgets via MCP Apps (SEP-1865), `ui://widget/.html` resources, CSP, bridge | ## Recommended Reading Order @@ -88,6 +89,7 @@ This is a router skill. The "steps" here are how to choose the right reference ( 8. **`create-skill`** / **`create-skill-with-tools`** — Author your own skills (meta) 9. **`official-adapters`** / **`openapi-adapter`** — Integrate external APIs via OpenAPI specs 10. **`official-plugins`** — Add caching, session memory, feature flags, and more +11. **`create-tool-ui`** — Add a rendered widget to a tool response (MCP Apps / SEP-1865) ## Cross-Cutting Patterns @@ -240,6 +242,14 @@ Each reference has matching examples under [`examples//`](./examples/ | [`zod-raw-shape-output`](./examples/create-tool-output-schema-types/zod-raw-shape-output.md) | Basic | Demonstrates the recommended approach of using a Zod raw shape as `outputSchema` for structured, validated JSON output. | | [`zod-schema-advanced-output`](./examples/create-tool-output-schema-types/zod-schema-advanced-output.md) | Advanced | Demonstrates using full Zod schema objects (not raw shapes) as `outputSchema`, including `z.object()`, `z.array()`, `z.union()`, and `z.discriminatedUnion()`. | +### `create-tool-ui` + +| 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 loads `chart.js` from `cdnjs.cloudflare.com` so it works in both OpenAI and Claude. | + ### `create-tool` | Example | Level | Description | @@ -299,4 +309,4 @@ when a server has been configured to host this skill. ## Reference - [FrontMCP Overview](https://docs.agentfront.dev/frontmcp/fundamentals/overview) -- Related skills: `create-tool`, `create-resource`, `create-prompt`, `create-agent`, `create-provider`, `create-job`, `create-workflow`, `create-skill`, `create-skill-with-tools`, `decorators-guide`, `official-adapters`, `openapi-adapter`, `official-plugins` +- Related skills: `create-tool`, `create-tool-ui`, `create-resource`, `create-prompt`, `create-agent`, `create-provider`, `create-job`, `create-workflow`, `create-skill`, `create-skill-with-tools`, `decorators-guide`, `official-adapters`, `openapi-adapter`, `official-plugins` diff --git a/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/basic-html-template.md b/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/basic-html-template.md new file mode 100644 index 00000000..9b45ee66 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/basic-html-template.md @@ -0,0 +1,106 @@ +--- +name: basic-html-template +reference: create-tool-ui +level: basic +description: 'A minimal function template that renders the tool output as a styled HTML card using `ctx.helpers.escapeHtml`.' +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' +--- + +# Basic HTML Widget (Function Template) + +A minimal function template that renders the tool output as a styled HTML card using `ctx.helpers.escapeHtml`. + +## Code + +```typescript +// src/apps/main/tools/get-weather.schema.ts +import { ToolInputOf, ToolOutputOf, z } from '@frontmcp/sdk'; + +export const inputSchema = { + location: z.string().describe('City name'), +}; + +export const outputSchema = { + location: z.string(), + temperatureF: z.number(), + conditions: z.string(), + humidityPct: z.number(), +}; + +export type GetWeatherInput = ToolInputOf<{ inputSchema: typeof inputSchema }>; +export type GetWeatherOutput = ToolOutputOf<{ outputSchema: typeof outputSchema }>; +``` + +```typescript +// src/apps/main/tools/get-weather.tool.ts +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { inputSchema, outputSchema, type GetWeatherInput, type GetWeatherOutput } from './get-weather.schema'; + +@Tool({ + name: 'get_weather', + description: 'Get current weather for a location', + inputSchema, + outputSchema, + ui: { + widgetDescription: 'Current weather card', + template: (ctx) => { + const { output, helpers } = ctx; + return ` +
+

${helpers.escapeHtml(output.location)}

+

${output.temperatureF}°F

+

${helpers.escapeHtml(output.conditions)}

+

Humidity: ${output.humidityPct}%

+
+ `; + }, + }, +}) +class GetWeatherTool extends ToolContext { + async execute(input: GetWeatherInput): Promise { + return { + location: input.location, + temperatureF: 72, + conditions: 'Sunny', + humidityPct: 55, + }; + } +} + +export { GetWeatherTool }; +``` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { GetWeatherTool } from './tools/get-weather.tool'; + +@App({ + name: 'main', + tools: [GetWeatherTool], +}) +class MainApp {} + +export { MainApp }; +``` + +## What This Demonstrates + +- 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 + +## Related + +- See `create-tool-ui` for the full options reference, serving modes, CSP, and bridge API +- See `create-tool` for the underlying `@Tool` decorator and `ToolContext` patterns diff --git a/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/file-source-tsx-widget.md b/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/file-source-tsx-widget.md new file mode 100644 index 00000000..8272c084 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/file-source-tsx-widget.md @@ -0,0 +1,155 @@ +--- +name: file-source-tsx-widget +reference: create-tool-ui +level: advanced +description: 'A `.tsx` FileSource widget that loads `chart.js` from `cdnjs.cloudflare.com` so it works in both OpenAI and Claude.' +tags: [development, tool, ui, widget, file-source, tsx, react, claude, cdn, mcp-apps] +features: + - 'Pointing `template` at a sibling `.tsx` file via the `FileSource` form: `{ file: ... }`' + - 'Resolving the file path relative to the tool source (not `process.cwd()`) using `import.meta.url`' + - 'Excluding `chart.js` from the bundle via `externals` and pinning a Claude-compatible CDN URL with `dependencies`' + - "Setting `resourceMode: 'inline'` so renderer scripts are embedded — required for Claude Artifacts" + - 'Setting `hydrate: false` (default) to avoid React error #418 inside the host iframe' +--- + +# File-Based `.tsx` Widget with CDN Externals + +A `.tsx` FileSource widget that loads `chart.js` from `cdnjs.cloudflare.com` so it works in both OpenAI and Claude. + +## Code + +```typescript +// src/apps/main/tools/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.tool.ts +import { fileURLToPath } from 'node:url'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { inputSchema, outputSchema, type SalesChartInput, type SalesChartOutput } from './sales-chart.schema'; + +// Resolve the sibling .tsx widget by absolute path. `template: { file: './x.tsx' }` +// is resolved against `process.cwd()` today (issue #444), so anchor to this file. +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 }, + // `chart.js` is loaded from CDN at runtime instead of bundled. + externals: ['chart.js'], + dependencies: { + 'chart.js': { + // Only cdnjs.cloudflare.com is reachable from Claude Artifacts. + url: 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js', + global: 'Chart', + }, + }, + // Embed the renderer scripts inline so the widget is self-contained for Claude. + resourceMode: 'inline', + // Static HTML only — no React hydration to dodge error #418 in iframe sandboxes. + hydrate: false, + }, +}) +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 })), + }; + } +} + +export { SalesChartTool }; +``` + +```tsx +// src/apps/main/tools/sales-chart.widget.tsx +import type { Chart as ChartType } from 'chart.js'; +import { useEffect, useRef } from 'react'; + +declare global { + interface Window { + Chart?: typeof ChartType; + } +} + +type Props = { + output: { year: number; monthly: Array<{ month: string; revenueUsd: number }> }; +}; + +export default function SalesChartWidget({ output }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + const ChartCtor = window.Chart; + if (!ChartCtor || !canvasRef.current) return; + const chart = new ChartCtor(canvasRef.current, { + type: 'bar', + data: { + labels: output.monthly.map((m) => m.month), + datasets: [{ label: `Revenue ${output.year}`, data: output.monthly.map((m) => m.revenueUsd) }], + }, + options: { responsive: true, maintainAspectRatio: false }, + }); + return () => chart.destroy(); + }, [output]); + + return ( +
+

Sales — {output.year}

+
+ +
+
+ ); +} +``` + +```typescript +// src/apps/main/index.ts +import { App } from '@frontmcp/sdk'; + +import { SalesChartTool } from './tools/sales-chart.tool'; + +@App({ + name: 'main', + tools: [SalesChartTool], +}) +class MainApp {} + +export { MainApp }; +``` + +## What This Demonstrates + +- Pointing `template` at a sibling `.tsx` file via the `FileSource` form: `{ file: ... }` +- Resolving the file path relative to the tool source (not `process.cwd()`) using `import.meta.url` +- Excluding `chart.js` from the bundle via `externals` and pinning a Claude-compatible CDN URL with `dependencies` +- Setting `resourceMode: 'inline'` so renderer scripts are embedded — required for Claude Artifacts +- Setting `hydrate: false` (default) to avoid React error #418 inside the host iframe + +## Related + +- See `create-tool-ui` for the full FileSource, CDN, and platform-compatibility reference +- See `create-tool` for the underlying `@Tool` decorator diff --git a/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/widget-with-csp-and-bridge.md b/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/widget-with-csp-and-bridge.md new file mode 100644 index 00000000..1f9c05c7 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/examples/create-tool-ui/widget-with-csp-and-bridge.md @@ -0,0 +1,173 @@ +--- +name: widget-with-csp-and-bridge +reference: create-tool-ui +level: intermediate +description: 'An interactive widget that fetches from an allow-listed origin via `csp.connectDomains` and invokes another tool via `window.FrontMcpBridge.callTool`.' +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 ` + + `; + }, + }, +}) +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'` | `'cdn'` loads React/MDX/Handlebars from CDN; `'inline'` embeds all scripts (Claude Artifacts) | +| `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 | **Only `cdnjs.cloudflare.com`** is reachable; prefer `resourceMode: 'inline'` for self-contained widgets | +| **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 | `resourceMode: 'inline'` + CDN deps on `cdnjs.cloudflare.com` | Default `'cdn'` + arbitrary CDN domains | Claude blocks all non-cdnjs origins; widget will hang forever | +| 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) | `.tsx` widget fetched from `esm.sh` (blocked) | Provide `dependencies` overrides on `cdnjs.cloudflare.com`, or precompile and use a function template | +| 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 | CSP must be on the _resource_, not the tool (issue #455) | Use `csp:` inside the `ui:` block — the resource handler emits it; don't put it on `_meta` | +| `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()` | Use an absolute path or `new URL('./widget.tsx', import.meta.url).pathname` until #444 lands | + +## 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 loads `chart.js` from `cdnjs.cloudflare.com` so it works in both OpenAI and Claude. | + +> 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/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index b3901469..e80ee5e4 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -1397,6 +1397,62 @@ } ] }, + { + "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 ``) | 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 00000000..82eebbff --- /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 00000000..0a39b60c --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md @@ -0,0 +1,121 @@ +--- +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 { 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}`); + 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 { 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}`); + 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 00000000..9ac97962 --- /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 00000000..be8edaff --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/26-tool-with-resource-link-output.md @@ -0,0 +1,94 @@ +--- +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(), + format: z.enum(['csv', 'json']).default('csv'), +}; +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; format: 'csv' | 'json' }) { + const exports = this.get(EXPORTS); + const exportId = await exports.create(input.datasetId, input.format); + + // Return JUST the URI. The client fetches the body via resources/read. + return { uri: `export://${exportId}.${input.format}` }; + } +} +``` + +## 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 00000000..e705b33f --- /dev/null +++ b/libs/skills/catalog/create-tool/examples/27-tool-with-examples-metadata.md @@ -0,0 +1,89 @@ +--- +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, 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: { amount: number; from: string; to: string }) { + 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 00000000..9b326f3e --- /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 00000000..818a5ae3 --- /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 00000000..dcfb0b45 --- /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 00000000..644f5b73 --- /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 00000000..8e71a1d4 --- /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 00000000..23b3f353 --- /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 00000000..beef1045 --- /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 00000000..7941d369 --- /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 00000000..d7a2e4ca --- /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) + +``` +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) + +``` +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 00000000..6e68dd7b --- /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 00000000..9cd82ede --- /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 00000000..bc9eb515 --- /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 00000000..7fffe3da --- /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 00000000..083fb80a --- /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 00000000..8790e87c --- /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 00000000..0e31bb26 --- /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 00000000..230548f9 --- /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: { 60, 60_000 }` (60/min) | +| Shared DB / GPU | `concurrency: { maxConcurrent: 5 }` | +| Anything calling LLMs / 3rd-party HTTP | `timeout: { executeMs: 30_000 }` | +| All three | `rateLimit: { 10, 60_000 }, concurrency: { 2 }, timeout: { 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 00000000..c1387a8e --- /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 00000000..c410a771 --- /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' src/**/*.tool.ts) +``` + +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 00000000..25ad9e78 --- /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*\{' src/**/*.tool.ts +``` 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 00000000..75e7e4bd --- /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 00000000..555c0dd2 --- /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 00000000..559e56a3 --- /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 00000000..84e31200 --- /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 00000000..2968601c --- /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 00000000..37fe877d --- /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 00000000..4207c1c3 --- /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 00000000..81916047 --- /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 4637ccf7..00000000 --- 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 8b202d04..880a8dfd 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).max(100)` 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 `