Loading skill
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.
| 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)Other measured skills in the registry, with their headline benchmark lift.