@mcp-web/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/create-app.d.ts +160 -0
- package/dist/create-app.d.ts.map +1 -0
- package/dist/create-app.js +94 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/internal.d.ts +12 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +10 -0
- package/dist/mcp-app-context.d.ts +122 -0
- package/dist/mcp-app-context.d.ts.map +1 -0
- package/dist/mcp-app-context.js +140 -0
- package/dist/mcp-app-context.test.d.ts +2 -0
- package/dist/mcp-app-context.test.d.ts.map +1 -0
- package/dist/mcp-app-context.test.js +254 -0
- package/dist/render-mcp-app.d.ts +71 -0
- package/dist/render-mcp-app.d.ts.map +1 -0
- package/dist/render-mcp-app.js +87 -0
- package/dist/use-mcp-app-props.d.ts +116 -0
- package/dist/use-mcp-app-props.d.ts.map +1 -0
- package/dist/use-mcp-app-props.js +158 -0
- package/dist/vite-plugin.d.ts +123 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +450 -0
- package/package.json +53 -0
- package/src/create-app.test.ts +181 -0
- package/src/create-app.ts +213 -0
- package/src/index.ts +87 -0
- package/src/internal.ts +12 -0
- package/src/mcp-app-context.test.tsx +323 -0
- package/src/mcp-app-context.tsx +183 -0
- package/src/render-mcp-app.tsx +141 -0
- package/src/use-mcp-app-props.ts +166 -0
- package/src/vite-plugin.test.ts +110 -0
- package/src/vite-plugin.ts +620 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { viteSingleFile } from 'vite-plugin-singlefile';
|
|
4
|
+
const CRITICAL_OVERRIDES = [
|
|
5
|
+
{
|
|
6
|
+
key: 'build.assetsInlineLimit',
|
|
7
|
+
required: Number.MAX_SAFE_INTEGER,
|
|
8
|
+
reason: 'All assets must be inlined for single-file output',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
key: 'build.cssCodeSplit',
|
|
12
|
+
required: false,
|
|
13
|
+
reason: 'CSS must be in a single file for inlining',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
key: 'base',
|
|
17
|
+
required: './',
|
|
18
|
+
reason: 'Relative paths required for self-contained HTML',
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Virtual module prefix for generated app entries.
|
|
23
|
+
*/
|
|
24
|
+
const VIRTUAL_PREFIX = 'virtual:mcp-app/';
|
|
25
|
+
const RESOLVED_VIRTUAL_PREFIX = `\0${VIRTUAL_PREFIX}`;
|
|
26
|
+
/**
|
|
27
|
+
* The MCP App runtime is now handled by the ext-apps protocol
|
|
28
|
+
* via `@modelcontextprotocol/ext-apps` React hooks bundled
|
|
29
|
+
* into the app's JavaScript. No injected script is needed.
|
|
30
|
+
*
|
|
31
|
+
* Previously, this was a <script> tag that listened for
|
|
32
|
+
* `postMessage({ props })` from the host. The ext-apps protocol
|
|
33
|
+
* uses JSON-RPC 2.0 messages instead (ui/initialize, tool-result, etc.)
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Get a nested property value from an object using dot notation.
|
|
37
|
+
*/
|
|
38
|
+
function getNestedValue(obj, path) {
|
|
39
|
+
return path.split('.').reduce((current, key) => {
|
|
40
|
+
if (current && typeof current === 'object' && key in current) {
|
|
41
|
+
return current[key];
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}, obj);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Convert a name to kebab-case for HTML file output.
|
|
48
|
+
*/
|
|
49
|
+
function toKebabCase(name) {
|
|
50
|
+
return name
|
|
51
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
52
|
+
.replace(/_/g, '-')
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Find the apps config file in the project.
|
|
57
|
+
*/
|
|
58
|
+
function findAppsConfigFile(projectRoot, customPath) {
|
|
59
|
+
if (customPath) {
|
|
60
|
+
const fullPath = path.resolve(projectRoot, customPath);
|
|
61
|
+
if (fs.existsSync(fullPath)) {
|
|
62
|
+
return fullPath;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const candidates = [
|
|
67
|
+
'src/mcp-apps.ts',
|
|
68
|
+
'src/mcp-apps.tsx',
|
|
69
|
+
'src/mcp/apps.ts',
|
|
70
|
+
'src/mcp/apps.tsx',
|
|
71
|
+
];
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
const fullPath = path.resolve(projectRoot, candidate);
|
|
74
|
+
if (fs.existsSync(fullPath)) {
|
|
75
|
+
return fullPath;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse the apps config file to extract app definitions.
|
|
82
|
+
*
|
|
83
|
+
* Uses simple regex parsing to extract:
|
|
84
|
+
* - Import statements for components
|
|
85
|
+
* - createApp calls with name and component properties
|
|
86
|
+
*/
|
|
87
|
+
function parseAppsConfig(configPath) {
|
|
88
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
89
|
+
const apps = [];
|
|
90
|
+
// Parse imports to map component names to their paths
|
|
91
|
+
const importMap = new Map();
|
|
92
|
+
const importRegex = /import\s+(?:{([^}]+)}|(\w+))\s+from\s+['"]([^'"]+)['"]/g;
|
|
93
|
+
let match;
|
|
94
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
95
|
+
const importPath = match[3];
|
|
96
|
+
if (match[2]) {
|
|
97
|
+
// Default import: import Foo from './foo'
|
|
98
|
+
importMap.set(match[2], importPath);
|
|
99
|
+
}
|
|
100
|
+
else if (match[1]) {
|
|
101
|
+
// Named imports: import { Foo, Bar as Baz } from './foo'
|
|
102
|
+
const namedImports = match[1].split(',').map((s) => s.trim());
|
|
103
|
+
for (const namedImport of namedImports) {
|
|
104
|
+
const parts = namedImport.split(/\s+as\s+/);
|
|
105
|
+
const localName = parts[1] || parts[0];
|
|
106
|
+
importMap.set(localName.trim(), importPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Find createApp calls - match the start, then find balanced braces
|
|
111
|
+
const createAppStartRegex = /export\s+(?:const|let|var)\s+(\w+)\s*=\s*createApp\s*\(\s*\{/g;
|
|
112
|
+
while ((match = createAppStartRegex.exec(content)) !== null) {
|
|
113
|
+
const exportName = match[1];
|
|
114
|
+
const startIndex = match.index + match[0].length - 1; // Position of opening {
|
|
115
|
+
// Find the matching closing brace using brace counting
|
|
116
|
+
let braceCount = 1;
|
|
117
|
+
let endIndex = startIndex + 1;
|
|
118
|
+
while (braceCount > 0 && endIndex < content.length) {
|
|
119
|
+
const char = content[endIndex];
|
|
120
|
+
if (char === '{')
|
|
121
|
+
braceCount++;
|
|
122
|
+
else if (char === '}')
|
|
123
|
+
braceCount--;
|
|
124
|
+
endIndex++;
|
|
125
|
+
}
|
|
126
|
+
if (braceCount !== 0) {
|
|
127
|
+
// Unbalanced braces, skip this match
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// Extract the config body (between the braces)
|
|
131
|
+
const configBody = content.slice(startIndex + 1, endIndex - 1);
|
|
132
|
+
// Extract name property - look for the first name: '...' or name: "..."
|
|
133
|
+
const nameMatch = configBody.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
|
134
|
+
if (!nameMatch)
|
|
135
|
+
continue;
|
|
136
|
+
const name = nameMatch[1];
|
|
137
|
+
// Extract component property - look for component: <identifier>
|
|
138
|
+
const componentMatch = configBody.match(/component\s*:\s*(\w+)/);
|
|
139
|
+
if (!componentMatch)
|
|
140
|
+
continue;
|
|
141
|
+
const componentName = componentMatch[1];
|
|
142
|
+
// Look up the component's import path
|
|
143
|
+
const componentImportPath = importMap.get(componentName);
|
|
144
|
+
if (!componentImportPath) {
|
|
145
|
+
console.warn(`[mcp-web-app] Warning: Could not find import for component "${componentName}" in app "${name}"`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
apps.push({
|
|
149
|
+
name,
|
|
150
|
+
exportName,
|
|
151
|
+
componentName,
|
|
152
|
+
componentImportPath,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return apps;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Resolve a component import path to an absolute path.
|
|
159
|
+
*/
|
|
160
|
+
function resolveComponentPath(componentImportPath, configDir) {
|
|
161
|
+
// If it starts with . or /, it's a relative/absolute path
|
|
162
|
+
if (componentImportPath.startsWith('.') ||
|
|
163
|
+
componentImportPath.startsWith('/')) {
|
|
164
|
+
// Resolve relative to the config file's directory
|
|
165
|
+
const resolved = path.resolve(configDir, componentImportPath);
|
|
166
|
+
// Try with common extensions
|
|
167
|
+
const extensions = ['.tsx', '.ts', '.jsx', '.js'];
|
|
168
|
+
for (const ext of extensions) {
|
|
169
|
+
if (fs.existsSync(resolved + ext)) {
|
|
170
|
+
return resolved + ext;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (fs.existsSync(resolved)) {
|
|
174
|
+
return resolved;
|
|
175
|
+
}
|
|
176
|
+
// Check for index files
|
|
177
|
+
for (const ext of extensions) {
|
|
178
|
+
const indexPath = path.join(resolved, `index${ext}`);
|
|
179
|
+
if (fs.existsSync(indexPath)) {
|
|
180
|
+
return indexPath;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
// Otherwise, it's a package import - return as-is for Vite to resolve
|
|
186
|
+
return componentImportPath;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Generate a virtual module that renders an MCP App.
|
|
190
|
+
*/
|
|
191
|
+
function generateVirtualModule(componentPath, componentName) {
|
|
192
|
+
return `
|
|
193
|
+
import { renderMCPApp } from '@mcp-web/app/internal';
|
|
194
|
+
import { ${componentName} } from '${componentPath}';
|
|
195
|
+
|
|
196
|
+
renderMCPApp(${componentName});
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Generate HTML content for an app entry.
|
|
201
|
+
*/
|
|
202
|
+
function generateHTMLContent(appName, virtualModulePath) {
|
|
203
|
+
return `<!DOCTYPE html>
|
|
204
|
+
<html lang="en" style="color-scheme: light dark">
|
|
205
|
+
<head>
|
|
206
|
+
<meta charset="UTF-8" />
|
|
207
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
208
|
+
<title>${appName}</title>
|
|
209
|
+
<style>body { margin: 0; background: transparent; }</style>
|
|
210
|
+
</head>
|
|
211
|
+
<body>
|
|
212
|
+
<div id="root"></div>
|
|
213
|
+
<script type="module" src="${virtualModulePath}"></script>
|
|
214
|
+
</body>
|
|
215
|
+
</html>`;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Internal plugin that handles virtual modules and build process.
|
|
219
|
+
*/
|
|
220
|
+
function mcpAppPlugin(projectRoot, outDir, configPath) {
|
|
221
|
+
let apps = [];
|
|
222
|
+
let configDir;
|
|
223
|
+
const virtualModules = new Map();
|
|
224
|
+
const htmlEntries = new Map();
|
|
225
|
+
return {
|
|
226
|
+
name: 'mcp-web-app',
|
|
227
|
+
configResolved(_config) {
|
|
228
|
+
// Store config for potential future use
|
|
229
|
+
},
|
|
230
|
+
buildStart() {
|
|
231
|
+
// Clear previous state
|
|
232
|
+
virtualModules.clear();
|
|
233
|
+
htmlEntries.clear();
|
|
234
|
+
if (!configPath) {
|
|
235
|
+
console.log('\n\u26A0 No MCP Apps config file found. Looking for src/mcp-apps.ts or src/mcp/apps.ts');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
configDir = path.dirname(configPath);
|
|
239
|
+
apps = parseAppsConfig(configPath);
|
|
240
|
+
if (apps.length === 0) {
|
|
241
|
+
console.log(`\n\u26A0 No apps found in ${path.relative(projectRoot, configPath)}`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Generate virtual modules for each app
|
|
245
|
+
for (const app of apps) {
|
|
246
|
+
const kebabName = toKebabCase(app.name);
|
|
247
|
+
const virtualId = `${VIRTUAL_PREFIX}${kebabName}`;
|
|
248
|
+
const resolvedVirtualId = `${RESOLVED_VIRTUAL_PREFIX}${kebabName}`;
|
|
249
|
+
// Resolve the component path
|
|
250
|
+
const absoluteComponentPath = resolveComponentPath(app.componentImportPath, configDir);
|
|
251
|
+
// Generate the virtual module content
|
|
252
|
+
const moduleContent = generateVirtualModule(absoluteComponentPath, app.componentName);
|
|
253
|
+
virtualModules.set(resolvedVirtualId, moduleContent);
|
|
254
|
+
// Generate HTML entry
|
|
255
|
+
const htmlContent = generateHTMLContent(kebabName, virtualId);
|
|
256
|
+
const htmlPath = path.join(outDir, `${kebabName}.html`);
|
|
257
|
+
htmlEntries.set(kebabName, htmlContent);
|
|
258
|
+
// Write temporary HTML file for Vite to use as entry
|
|
259
|
+
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
260
|
+
}
|
|
261
|
+
console.log(`\n\u2139 Found ${apps.length} MCP App(s) in ${path.relative(projectRoot, configPath)}`);
|
|
262
|
+
},
|
|
263
|
+
resolveId(id) {
|
|
264
|
+
if (id.startsWith(VIRTUAL_PREFIX)) {
|
|
265
|
+
return `\0${id}`;
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
},
|
|
269
|
+
load(id) {
|
|
270
|
+
// Handle virtual app modules
|
|
271
|
+
if (id.startsWith(RESOLVED_VIRTUAL_PREFIX)) {
|
|
272
|
+
return virtualModules.get(id) || null;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
},
|
|
276
|
+
generateBundle() {
|
|
277
|
+
// Don't emit additional HTML - let vite-plugin-singlefile handle the output
|
|
278
|
+
// The inlined HTML will be at a nested path which we'll fix in closeBundle
|
|
279
|
+
},
|
|
280
|
+
closeBundle() {
|
|
281
|
+
if (apps.length === 0)
|
|
282
|
+
return;
|
|
283
|
+
// The inlined HTML files are output to a nested path matching the temp directory structure
|
|
284
|
+
// Move them to the root of the output directory
|
|
285
|
+
const tempSubDir = path.join(outDir, 'node_modules', '.mcp-web-app-temp');
|
|
286
|
+
if (fs.existsSync(tempSubDir)) {
|
|
287
|
+
for (const app of apps) {
|
|
288
|
+
const kebabName = toKebabCase(app.name);
|
|
289
|
+
const srcPath = path.join(tempSubDir, `${kebabName}.html`);
|
|
290
|
+
const destPath = path.join(outDir, `${kebabName}.html`);
|
|
291
|
+
if (fs.existsSync(srcPath)) {
|
|
292
|
+
// Move the inlined HTML to the root
|
|
293
|
+
fs.copyFileSync(srcPath, destPath);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Clean up the nested directory
|
|
297
|
+
fs.rmSync(path.join(outDir, 'node_modules'), { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
const relOutDir = path.relative(projectRoot, outDir);
|
|
300
|
+
console.log(`\n\u2713 Built ${apps.length} MCP App(s) to ${relOutDir}/`);
|
|
301
|
+
for (const app of apps) {
|
|
302
|
+
const kebabName = toKebabCase(app.name);
|
|
303
|
+
console.log(` - ${kebabName}.html (${app.exportName})`);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Define a Vite configuration for building MCP Apps as single HTML files.
|
|
310
|
+
*
|
|
311
|
+
* This function creates a complete Vite config that auto-discovers app
|
|
312
|
+
* definitions and bundles them into self-contained HTML files. These files
|
|
313
|
+
* can be served as MCP App resources that render inline in AI chat interfaces.
|
|
314
|
+
*
|
|
315
|
+
* **How it works:**
|
|
316
|
+
* 1. Scans for a config file (`src/mcp-apps.ts` or `src/mcp/apps.ts`)
|
|
317
|
+
* 2. Parses `createApp()` calls to find app names and components
|
|
318
|
+
* 3. Auto-generates entry files that render each component
|
|
319
|
+
* 4. Bundles everything into single HTML files
|
|
320
|
+
*
|
|
321
|
+
* **Features:**
|
|
322
|
+
* - No manual entry files needed - just define your apps!
|
|
323
|
+
* - Full Vite config flexibility - pass any standard Vite options
|
|
324
|
+
* - Automatic single-file bundling (JS, CSS, assets all inlined)
|
|
325
|
+
* - Watch mode support for development
|
|
326
|
+
* - PostMessage runtime for receiving props from the host
|
|
327
|
+
*
|
|
328
|
+
* **Critical settings that are always enforced:**
|
|
329
|
+
* - `build.assetsInlineLimit` → Maximum (for inlining)
|
|
330
|
+
* - `build.cssCodeSplit` → false (single CSS bundle)
|
|
331
|
+
* - `base` → './' (relative paths)
|
|
332
|
+
*
|
|
333
|
+
* @param viteConfig - Standard Vite configuration options (plugins, build settings, etc.)
|
|
334
|
+
* @param mcpOptions - MCP-specific options (appsConfig, outDir, silenceOverrideWarnings)
|
|
335
|
+
* @returns A Vite UserConfigExport ready for use in vite.config.ts
|
|
336
|
+
*
|
|
337
|
+
* @example Basic Usage
|
|
338
|
+
* ```typescript
|
|
339
|
+
* // vite.apps.config.ts
|
|
340
|
+
* import react from '@vitejs/plugin-react';
|
|
341
|
+
* import { defineMCPAppsConfig } from '@mcp-web/app/vite';
|
|
342
|
+
*
|
|
343
|
+
* export default defineMCPAppsConfig({
|
|
344
|
+
* plugins: [react()],
|
|
345
|
+
* });
|
|
346
|
+
* ```
|
|
347
|
+
*
|
|
348
|
+
* @example With Custom Options
|
|
349
|
+
* ```typescript
|
|
350
|
+
* import react from '@vitejs/plugin-react';
|
|
351
|
+
* import { defineMCPAppsConfig } from '@mcp-web/app/vite';
|
|
352
|
+
*
|
|
353
|
+
* export default defineMCPAppsConfig(
|
|
354
|
+
* {
|
|
355
|
+
* plugins: [react()],
|
|
356
|
+
* build: {
|
|
357
|
+
* sourcemap: true,
|
|
358
|
+
* minify: 'terser',
|
|
359
|
+
* },
|
|
360
|
+
* },
|
|
361
|
+
* {
|
|
362
|
+
* appsConfig: 'src/my-apps.ts',
|
|
363
|
+
* outDir: 'dist/apps',
|
|
364
|
+
* }
|
|
365
|
+
* );
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* @example Apps Config File (src/mcp-apps.ts)
|
|
369
|
+
* ```typescript
|
|
370
|
+
* import { createApp } from '@mcp-web/app';
|
|
371
|
+
* import { Statistics } from './components/Statistics';
|
|
372
|
+
*
|
|
373
|
+
* export const statisticsApp = createApp({
|
|
374
|
+
* name: 'show_statistics',
|
|
375
|
+
* description: 'Display statistics visualization',
|
|
376
|
+
* component: Statistics,
|
|
377
|
+
* handler: () => ({
|
|
378
|
+
* completionRate: 0.75,
|
|
379
|
+
* totalTasks: 100,
|
|
380
|
+
* }),
|
|
381
|
+
* });
|
|
382
|
+
* ```
|
|
383
|
+
*/
|
|
384
|
+
export function defineMCPAppsConfig(viteConfig = {}, mcpOptions = {}) {
|
|
385
|
+
const { appsConfig, outDir = 'public/mcp-web-apps', silenceOverrideWarnings = false, } = mcpOptions;
|
|
386
|
+
const projectRoot = process.cwd();
|
|
387
|
+
const outDirPath = path.resolve(projectRoot, outDir);
|
|
388
|
+
// Find the apps config file
|
|
389
|
+
const configPath = findAppsConfigFile(projectRoot, appsConfig);
|
|
390
|
+
// Parse apps to generate rollup inputs
|
|
391
|
+
let apps = [];
|
|
392
|
+
let tempHtmlDir = null;
|
|
393
|
+
const input = {};
|
|
394
|
+
if (configPath) {
|
|
395
|
+
apps = parseAppsConfig(configPath);
|
|
396
|
+
if (apps.length > 0) {
|
|
397
|
+
// Create a temp directory for HTML entries
|
|
398
|
+
tempHtmlDir = path.join(projectRoot, 'node_modules', '.mcp-web-app-temp');
|
|
399
|
+
fs.mkdirSync(tempHtmlDir, { recursive: true });
|
|
400
|
+
// Generate HTML entries for rollup input
|
|
401
|
+
for (const app of apps) {
|
|
402
|
+
const kebabName = toKebabCase(app.name);
|
|
403
|
+
const htmlPath = path.join(tempHtmlDir, `${kebabName}.html`);
|
|
404
|
+
const virtualModulePath = `${VIRTUAL_PREFIX}${kebabName}`;
|
|
405
|
+
const htmlContent = generateHTMLContent(kebabName, virtualModulePath);
|
|
406
|
+
fs.writeFileSync(htmlPath, htmlContent);
|
|
407
|
+
input[kebabName] = htmlPath;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Check for and warn about overridden settings
|
|
412
|
+
if (!silenceOverrideWarnings) {
|
|
413
|
+
const userConfig = viteConfig;
|
|
414
|
+
for (const override of CRITICAL_OVERRIDES) {
|
|
415
|
+
const userValue = getNestedValue(userConfig, override.key);
|
|
416
|
+
if (userValue !== undefined && userValue !== override.required) {
|
|
417
|
+
console.warn(`[mcp-web-app] Warning: Overriding ${override.key} ` +
|
|
418
|
+
`(was: ${JSON.stringify(userValue)}, now: ${JSON.stringify(override.required)}). ` +
|
|
419
|
+
`Reason: ${override.reason}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Build the merged config
|
|
424
|
+
const mergedConfig = {
|
|
425
|
+
...viteConfig,
|
|
426
|
+
// These are always set by MCP Apps
|
|
427
|
+
base: './',
|
|
428
|
+
plugins: [
|
|
429
|
+
// User plugins first
|
|
430
|
+
...(viteConfig.plugins || []),
|
|
431
|
+
// Then our plugins
|
|
432
|
+
viteSingleFile(),
|
|
433
|
+
mcpAppPlugin(projectRoot, outDirPath, configPath),
|
|
434
|
+
],
|
|
435
|
+
build: {
|
|
436
|
+
...viteConfig.build,
|
|
437
|
+
outDir: outDirPath,
|
|
438
|
+
emptyOutDir: true,
|
|
439
|
+
// Critical overrides for single-file output
|
|
440
|
+
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
|
441
|
+
cssCodeSplit: false,
|
|
442
|
+
rollupOptions: {
|
|
443
|
+
...viteConfig.build?.rollupOptions,
|
|
444
|
+
input: Object.keys(input).length > 0 ? input : undefined,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
return mergedConfig;
|
|
449
|
+
}
|
|
450
|
+
export default defineMCPAppsConfig;
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcp-web/app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build tooling for MCP Apps - bundle React components into single HTML files for AI UI rendering",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./vite": {
|
|
14
|
+
"import": "./dist/vite-plugin.js",
|
|
15
|
+
"types": "./dist/vite-plugin.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./internal": {
|
|
18
|
+
"import": "./dist/internal.js",
|
|
19
|
+
"types": "./dist/internal.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/ext-apps": "^1.0.1",
|
|
24
|
+
"vite-plugin-singlefile": "^2.3.0",
|
|
25
|
+
"zod": "~4.1.12",
|
|
26
|
+
"@mcp-web/types": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.0.9",
|
|
30
|
+
"@types/react": "^18.3.24",
|
|
31
|
+
"@types/react-dom": "^18.3.7",
|
|
32
|
+
"typescript": "~5.9.3",
|
|
33
|
+
"vite": "^7.3.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=17.0.0",
|
|
37
|
+
"react-dom": ">=17.0.0",
|
|
38
|
+
"vite": ">=5.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"react-dom": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"clean": "rm -rf dist",
|
|
51
|
+
"test": "bun test"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ComponentType } from 'react';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createApp, isCreatedApp } from '../src/create-app';
|
|
5
|
+
|
|
6
|
+
// Mock components for testing
|
|
7
|
+
const MockComponent: ComponentType<{ value: number }> = () => null;
|
|
8
|
+
const MockResultComponent: ComponentType<{ result: string }> = () => null;
|
|
9
|
+
const MockInputComponent: ComponentType<{
|
|
10
|
+
greeting: string;
|
|
11
|
+
doubled: number;
|
|
12
|
+
}> = () => null;
|
|
13
|
+
const MockEmptyComponent: ComponentType<Record<string, unknown>> = () => null;
|
|
14
|
+
const MockOutputComponent: ComponentType<{ output: string }> = () => null;
|
|
15
|
+
const MockAsyncComponent: ComponentType<{ async: boolean }> = () => null;
|
|
16
|
+
|
|
17
|
+
describe('createApp', () => {
|
|
18
|
+
test('creates a valid app definition', () => {
|
|
19
|
+
const app = createApp({
|
|
20
|
+
name: 'test_app',
|
|
21
|
+
description: 'A test app',
|
|
22
|
+
component: MockComponent,
|
|
23
|
+
handler: () => ({ value: 42 }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(app.__brand).toBe('CreatedApp');
|
|
27
|
+
expect(app.definition.name).toBe('test_app');
|
|
28
|
+
expect(app.definition.description).toBe('A test app');
|
|
29
|
+
expect(typeof app.definition.handler).toBe('function');
|
|
30
|
+
expect(app.definition.component).toBe(MockComponent);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('preserves handler functionality', async () => {
|
|
34
|
+
const app = createApp({
|
|
35
|
+
name: 'handler_test',
|
|
36
|
+
description: 'Test handler',
|
|
37
|
+
component: MockResultComponent,
|
|
38
|
+
handler: () => ({ result: 'success' }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await app.definition.handler();
|
|
42
|
+
expect(result).toEqual({ result: 'success' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('handler receives input when inputSchema is defined', async () => {
|
|
46
|
+
const InputSchema = z.object({
|
|
47
|
+
name: z.string(),
|
|
48
|
+
count: z.number(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const app = createApp({
|
|
52
|
+
name: 'input_test',
|
|
53
|
+
description: 'Test input',
|
|
54
|
+
component: MockInputComponent,
|
|
55
|
+
inputSchema: InputSchema,
|
|
56
|
+
handler: ({ name, count }) => ({
|
|
57
|
+
greeting: `Hello ${name}`,
|
|
58
|
+
doubled: count * 2,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await app.definition.handler({ name: 'World', count: 21 });
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
greeting: 'Hello World',
|
|
65
|
+
doubled: 42,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('preserves optional url and resourceUri', () => {
|
|
70
|
+
const app = createApp({
|
|
71
|
+
name: 'custom_urls',
|
|
72
|
+
description: 'Custom URLs',
|
|
73
|
+
component: MockEmptyComponent,
|
|
74
|
+
handler: () => ({}),
|
|
75
|
+
url: '/custom/app.html',
|
|
76
|
+
resourceUri: 'ui://custom/app',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(app.definition.url).toBe('/custom/app.html');
|
|
80
|
+
expect(app.definition.resourceUri).toBe('ui://custom/app');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('preserves inputSchema and propsSchema', () => {
|
|
84
|
+
const InputSchema = z.object({ input: z.string() });
|
|
85
|
+
const PropsSchema = z.object({ output: z.string() });
|
|
86
|
+
|
|
87
|
+
const app = createApp({
|
|
88
|
+
name: 'schema_test',
|
|
89
|
+
description: 'Schema test',
|
|
90
|
+
component: MockOutputComponent,
|
|
91
|
+
inputSchema: InputSchema,
|
|
92
|
+
propsSchema: PropsSchema,
|
|
93
|
+
handler: ({ input }) => ({ output: input.toUpperCase() }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(app.definition.inputSchema).toBe(InputSchema);
|
|
97
|
+
expect(app.definition.propsSchema).toBe(PropsSchema);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('throws error for invalid app definition - missing name', () => {
|
|
101
|
+
expect(() => {
|
|
102
|
+
createApp({
|
|
103
|
+
name: '',
|
|
104
|
+
description: 'Test',
|
|
105
|
+
component: MockEmptyComponent,
|
|
106
|
+
handler: () => ({}),
|
|
107
|
+
});
|
|
108
|
+
}).toThrow('Invalid app definition');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('throws error for invalid app definition - missing description', () => {
|
|
112
|
+
expect(() => {
|
|
113
|
+
createApp({
|
|
114
|
+
name: 'test',
|
|
115
|
+
description: '',
|
|
116
|
+
component: MockEmptyComponent,
|
|
117
|
+
handler: () => ({}),
|
|
118
|
+
});
|
|
119
|
+
}).toThrow('Invalid app definition');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('async handler works correctly', async () => {
|
|
123
|
+
const app = createApp({
|
|
124
|
+
name: 'async_test',
|
|
125
|
+
description: 'Async handler test',
|
|
126
|
+
component: MockAsyncComponent,
|
|
127
|
+
handler: async () => {
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
129
|
+
return { async: true };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = await app.definition.handler();
|
|
134
|
+
expect(result).toEqual({ async: true });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('isCreatedApp', () => {
|
|
139
|
+
test('returns true for CreatedApp', () => {
|
|
140
|
+
const app = createApp({
|
|
141
|
+
name: 'test',
|
|
142
|
+
description: 'test',
|
|
143
|
+
component: MockEmptyComponent,
|
|
144
|
+
handler: () => ({}),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(isCreatedApp(app)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('returns false for null', () => {
|
|
151
|
+
expect(isCreatedApp(null)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('returns false for undefined', () => {
|
|
155
|
+
expect(isCreatedApp(undefined)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('returns false for plain object', () => {
|
|
159
|
+
expect(isCreatedApp({})).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('returns false for object with wrong brand', () => {
|
|
163
|
+
expect(isCreatedApp({ __brand: 'NotCreatedApp' })).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('returns false for app definition without brand', () => {
|
|
167
|
+
expect(
|
|
168
|
+
isCreatedApp({
|
|
169
|
+
name: 'test',
|
|
170
|
+
description: 'test',
|
|
171
|
+
handler: () => ({}),
|
|
172
|
+
})
|
|
173
|
+
).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('returns false for primitives', () => {
|
|
177
|
+
expect(isCreatedApp('string')).toBe(false);
|
|
178
|
+
expect(isCreatedApp(123)).toBe(false);
|
|
179
|
+
expect(isCreatedApp(true)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|