mcp-tool-resource-pattern
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.
Overview
Every MCP App is built on the Tool + Resource pattern:
- Tool (registered via
registerAppTool): Called by the LLM/host, returns data. Its_meta.ui.resourceUritells the host which Resource provides the UI. - Resource (registered via
registerAppResource): Serves a bundled HTML file that renders the interactive UI in a sandboxed iframe. - The tool passes data to the UI via
structuredContent(available inontoolresulthandler). - The tool MUST also return a
contentarray with text fallback for non-UI hosts.
Capabilities
registerAppTool Implementation
- Register tools with
_meta.ui.resourceUrilinking to a resource - Pass data via
structuredContentfor rich UI rendering - Always include
contentarray with text fallback - Configure tool input schemas via Zod
registerAppResource Implementation
- Register HTML resources with
RESOURCE_MIME_TYPE - Serve single-file bundled HTML
- Configure CSP domains in
contents[]return - Support multiple tools sharing the same resource URI
App-Only Helper Tools
- Create tools with
visibility: ['app']-- only callable from the UI iframe, not by the LLM - Use cases: polling for updates, loading additional data, pagination, state mutations
- Implement via
app.callServerTool()from client-side
Graceful Degradation
- Detect UI capability via
getUiCapability()on the server - Return richer responses when UI is available
- Always maintain text-only fallback path
Usage
Basic Tool + Resource Pattern
import { 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,
},
};
},
});
App-Only Helper Tools
// 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,
};
},
});
Client-Side: Calling App-Only Tools
import { 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);
}
Multiple Tools Sharing One Resource
// 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);
}
};
Graceful Degradation
import { 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),
}],
};
},
});
Critical Rules
- Tool's
resourceUrimust match a registered resource URI -- if the resource URI isapp:///my-app, the tool must reference exactlyapp:///my-app. - Always include
contentarray with text fallback -- non-UI hosts (terminal CLIs, basic chat clients) need a text representation. - CSP goes in
contents[]of the resource read callback -- NOT in_metaon the tool. - Use
RESOURCE_MIME_TYPEconstant -- never hardcode the MIME type string.
Verification Checklist
- [ ]
registerAppToolcalled withresourceUrimatching a registered resource - [ ]
registerAppResourcecalled with matching URI andRESOURCE_MIME_TYPE - [ ] Tool handler returns
contentarray with text fallback - [ ] Tool handler returns
structuredContentfor UI data - [ ]
RESOURCE_MIME_TYPEimported and used (not hardcoded string) - [ ] App-only tools use
visibility: ['app'] - [ ] Resource read callback returns
contents[]with CSP if needed - [ ] Multiple tools sharing a resource use the same URI
Task Definition
const 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`
}
};
}
});
Applicable Processes
- create-mcp-app.js
- add-app-to-mcp-server.js
- convert-web-app-to-mcp.js
- migrate-openai-app-to-mcp.js
External Dependencies
@modelcontextprotocol/ext-apps(registerAppTool, registerAppResource, RESOURCE_MIME_TYPE)@modelcontextprotocol/sdk(McpServer)zod(input schema validation)
References
Related Skills
- mcp-app-scaffolding
- mcp-host-styling-integration
- mcp-csp-investigation
- single-file-bundling
- mcp-app-verification
Related Agents
- mcp-app-architect
- mcp-ui-developer