Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Implements the core MCP Apps architectural pattern where a Tool declares _meta.ui.resourceUri referencing a registered Resource. Covers registerAppTool, registerAppResource, text fallback, structuredContent, and app-only helper tools.
.claude/skills/a5c-ai-mcp-tool-resource-pattern/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 99% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 49% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 97% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 66% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 69% | 0% |
Implement the foundational Tool + Resource pattern that every MCP App requires: a Tool that returns data and references a Resource that serves the interactive UI.
Every MCP App is built on the Tool + Resource pattern:
registerAppTool): Called by the LLM/host, returns data. Its _meta.ui.resourceUri tells the host which Resource provides the UI.registerAppResource): Serves a bundled HTML file that renders the interactive UI in a sandboxed iframe.structuredContent (available in ontoolresult handler).content array with text fallback for non-UI hosts._meta.ui.resourceUri linking to a resourcestructuredContent for rich UI renderingcontent array with text fallbackRESOURCE_MIME_TYPEcontents[] returnvisibility: ['app'] -- only callable from the UI iframe, not by the LLMapp.callServerTool() from client-sidegetUiCapability() on the servertypescriptimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from '@modelcontextprotocol/ext-apps'; import { z } from 'zod'; import fs from 'fs'; import path from 'path'; const server = new McpServer({ name: 'my-app', version: '1.0.0' }); // Read the bundled HTML (built by vite-plugin-singlefile) const bundledHtml = fs.readFileSync( path.join(__dirname, '../dist/mcp-app.html'), 'utf-8' ); // 1. Register the Resource (serves the UI) registerAppResource(server, { uri: 'app:///my-app', name: 'My App UI', mimeType: RESOURCE_MIME_TYPE, async read() { return { contents: [{ uri: 'app:///my-app', mimeType: RESOURCE_MIME_TYPE, text: bundledHtml, // CSP domains (if needed) // resourceDomains: ['https://cdn.example.com'], // connectDomains: ['https://api.example.com'], }], }; }, }); // 2. Register the Tool (returns data, references the resource) registerAppTool(server, { name: 'show_dashboard', description: 'Show an interactive dashboard', inputSchema: { type: 'object' as const, properties: { query: { type: 'string', description: 'Search query' }, }, required: ['query'], }, // _meta.ui.resourceUri is set automatically by registerAppTool resourceUri: 'app:///my-app', async handler(args) { const data = await fetchDashboardData(args.query); return { // Text fallback for non-UI hosts (REQUIRED) content: [ { type: 'text' as const, text: `Dashboard results for "${args.query}":\n${formatAsText(data)}`, }, ], // Rich data for the UI (available in ontoolresult handler) structuredContent: { query: args.query, results: data.results, metadata: data.metadata, }, }; }, });
typescript// This tool is ONLY callable from the UI iframe via app.callServerTool() // The LLM/host cannot call it directly registerAppTool(server, { name: 'load_page', description: 'Load a specific page of results', visibility: ['app'], // App-only: not visible to LLM inputSchema: { type: 'object' as const, properties: { page: { type: 'number' }, pageSize: { type: 'number' }, }, required: ['page'], }, resourceUri: 'app:///my-app', async handler(args) { const data = await fetchPage(args.page, args.pageSize || 20); return { content: [{ type: 'text' as const, text: JSON.stringify(data) }], structuredContent: data, }; }, });
typescriptimport { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps'; const app = new App({ transport: new PostMessageTransport() }); // Call an app-only tool from the UI async function loadNextPage(page: number) { const result = await app.callServerTool('load_page', { page, pageSize: 20, }); renderResults(result.structuredContent); }
typescript// Both tools reference the same resource URI // The UI handles both by checking which tool triggered registerAppTool(server, { name: 'search_products', description: 'Search for products', resourceUri: 'app:///product-viewer', // ... }); registerAppTool(server, { name: 'show_product_details', description: 'Show details for a specific product', resourceUri: 'app:///product-viewer', // Same resource // ... }); // In the UI, distinguish via ontoolinput handler: app.ontoolinput = (params) => { if (params.toolName === 'search_products') { renderSearchResults(params.arguments); } else if (params.toolName === 'show_product_details') { renderProductDetails(params.arguments); } };
typescriptimport { getUiCapability } from '@modelcontextprotocol/ext-apps'; registerAppTool(server, { name: 'show_chart', description: 'Display data as a chart', resourceUri: 'app:///chart-viewer', async handler(args) { const data = await getData(args); const uiCapability = getUiCapability(); // Rich response when UI is available if (uiCapability === 'full') { return { content: [{ type: 'text' as const, text: formatAsTable(data) }], structuredContent: { chartType: 'bar', labels: data.labels, values: data.values, }, }; } // Text-only response for non-UI hosts return { content: [{ type: 'text' as const, text: formatAsAsciiChart(data), }], }; }, });
resourceUri must match a registered resource URI -- if the resource URI is app:///my-app, the tool must reference exactly app:///my-app.content array with text fallback -- non-UI hosts (terminal CLIs, basic chat clients) need a text representation.contents[] of the resource read callback -- NOT in _meta on the tool.RESOURCE_MIME_TYPE constant -- never hardcode the MIME type string.registerAppTool called with resourceUri matching a registered resourceregisterAppResource called with matching URI and RESOURCE_MIME_TYPEcontent array with text fallbackstructuredContent for UI dataRESOURCE_MIME_TYPE imported and used (not hardcoded string)visibility: ['app']contents[] with CSP if neededjavascriptconst mcpToolResourcePatternTask = defineTask({ name: 'mcp-tool-resource-pattern', description: 'Implement Tool + Resource pattern for MCP App', inputs: { tools: { type: 'array', required: true }, resourceUri: { type: 'string', required: true }, appOnlyTools: { type: 'array', default: [] }, cspDomains: { type: 'object', default: {} } }, outputs: { toolsRegistered: { type: 'number' }, resourceRegistered: { type: 'boolean' }, artifacts: { type: 'array' } }, async run(inputs, taskCtx) { return { kind: 'skill', title: `Implement Tool + Resource pattern (${inputs.tools.length} tools)`, skill: { name: 'mcp-tool-resource-pattern', context: { tools: inputs.tools, resourceUri: inputs.resourceUri, appOnlyTools: inputs.appOnlyTools, cspDomains: inputs.cspDomains, instructions: [ 'Register resource with RESOURCE_MIME_TYPE and bundled HTML', 'Register each tool with resourceUri linking to the resource', 'Include text content fallback in every tool handler', 'Pass rich data via structuredContent', 'Create app-only helper tools with visibility: [app]', 'Configure CSP in contents[] if external origins needed' ] } }, io: { inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, outputJsonPath: `tasks/${taskCtx.effectId}/result.json` } }; } });
@modelcontextprotocol/ext-apps (registerAppTool, registerAppResource, RESOURCE_MIME_TYPE)@modelcontextprotocol/sdk (McpServer)zod (input schema validation)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 14,016 | 15,420 | +10% | 1 | 1 | 0% | 3,346 | 6,674 | +99% | 0 | 0 | — |
case-02 | fail→pass | 13,806 | 9,710 | -30% | 1 | 1 | 0% | 3,368 | 5,030 | +49% | 0 | 0 | — |
case-03 | fail→pass | 11,945 | 11,391 | -5% | 1 | 1 | 0% | 2,737 | 5,385 | +97% | 0 | 0 | — |
case-04 | fail→fail | 4,711 | 6,424 | +36% | 1 | 1 | 0% | 986 | 3,989 | +305% | 0 | 0 | — |
case-05 | fail→fail | 5,179 | 5,314 | +3% | 1 | 1 | 0% | 851 | 3,690 | +334% | 0 | 0 | — |
case-06 | fail→fail | 11,655 | 8,823 | -24% | 1 | 1 | 0% | 2,332 | 4,609 | +98% | 0 | 0 | — |
case-07 | fail→pass | 11,631 | 6,051 | -48% | 1 | 1 | 0% | 2,490 | 4,129 | +66% | 0 | 0 | — |
case-08 | fail→fail | 10,344 | 4,370 | -58% | 1 | 1 | 0% | 2,059 | 3,612 | +75% | 0 | 0 | — |
case-09 | fail→pass | 10,824 | 4,668 | -57% | 1 | 1 | 0% | 2,169 | 3,668 | +69% | 0 | 0 | — |
case-10 | fail→pass | 12,632 | 7,644 | -39% | 1 | 1 | 0% | 2,258 | 4,348 | +93% | 0 | 0 | — |
case-11 | fail→pass | 7,358 | 6,846 | -7% | 1 | 1 | 0% | 1,422 | 4,290 | +202% | 0 | 0 | — |
case-12 | fail→pass | 12,171 | 10,826 | -11% | 1 | 1 | 0% | 2,539 | 5,180 | +104% | 0 | 0 | — |
case-13 | fail→pass | 9,908 | 3,152 | -68% | 1 | 1 | 0% | 2,038 | 3,387 | +66% | 0 | 0 | — |
case-14 | fail→fail | 7,888 | 4,326 | -45% | 1 | 1 | 0% | 1,221 | 3,518 | +188% | 0 | 0 | — |
case-15 | fail→pass | 15,880 | 3,217 | -80% | 1 | 1 | 0% | 2,901 | 3,386 | +17% | 0 | 0 | — |
case-16 | fail→fail | 9,766 | 7,188 | -26% | 1 | 1 | 0% | 1,590 | 4,207 | +165% | 0 | 0 | — |
case-17 | fail→fail | 10,549 | 4,410 | -58% | 1 | 1 | 0% | 1,909 | 3,599 | +89% | 0 | 0 | — |
case-18 | fail→pass | 12,888 | 4,388 | -66% | 1 | 1 | 0% | 2,458 | 3,599 | +46% | 0 | 0 | — |
case-19 | fail→pass | 11,142 | 5,363 | -52% | 1 | 1 | 0% | 2,007 | 3,806 | +90% | 0 | 0 | — |
case-20 | fail→pass | 13,488 | 8,553 | -37% | 1 | 1 | 0% | 2,907 | 4,872 | +68% | 0 | 0 | — |
case-21 | fail→pass | 12,213 | 5,575 | -54% | 1 | 1 | 0% | 2,324 | 4,012 | +73% | 0 | 0 | — |
case-22 | fail→fail | 6,211 | 3,590 | -42% | 1 | 1 | 0% | 1,197 | 3,444 | +188% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +64 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.