@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.
@@ -0,0 +1,620 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { Plugin, UserConfig, UserConfigExport } from 'vite';
4
+ import { viteSingleFile } from 'vite-plugin-singlefile';
5
+
6
+ /**
7
+ * MCP-specific options for building MCP Apps.
8
+ *
9
+ * These options control how app definitions are discovered and where
10
+ * the bundled HTML files are output.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * defineMCPAppsConfig(
15
+ * { plugins: [react()] },
16
+ * {
17
+ * appsConfig: 'src/mcp-apps.ts',
18
+ * outDir: 'dist/apps',
19
+ * silenceOverrideWarnings: true,
20
+ * }
21
+ * );
22
+ * ```
23
+ */
24
+ export interface MCPAppOptions {
25
+ /**
26
+ * Path to the apps configuration file (relative to project root).
27
+ * This file should export apps created with `createApp()`.
28
+ *
29
+ * If not specified, will search for:
30
+ * - `src/mcp-apps.ts`
31
+ * - `src/mcp-apps.tsx`
32
+ * - `src/mcp/apps.ts`
33
+ * - `src/mcp/apps.tsx`
34
+ *
35
+ * @default auto-detected
36
+ */
37
+ appsConfig?: string;
38
+
39
+ /**
40
+ * Output directory for bundled app HTML files (relative to project root).
41
+ * @default 'public/mcp-web-apps'
42
+ */
43
+ outDir?: string;
44
+
45
+ /**
46
+ * Silence warnings when MCP-required settings override user-provided values.
47
+ * @default false
48
+ */
49
+ silenceOverrideWarnings?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Settings that must be overridden for single-file MCP App output to work.
54
+ */
55
+ interface OverriddenSettings {
56
+ key: string;
57
+ required: unknown;
58
+ reason: string;
59
+ }
60
+
61
+ const CRITICAL_OVERRIDES: OverriddenSettings[] = [
62
+ {
63
+ key: 'build.assetsInlineLimit',
64
+ required: Number.MAX_SAFE_INTEGER,
65
+ reason: 'All assets must be inlined for single-file output',
66
+ },
67
+ {
68
+ key: 'build.cssCodeSplit',
69
+ required: false,
70
+ reason: 'CSS must be in a single file for inlining',
71
+ },
72
+ {
73
+ key: 'base',
74
+ required: './',
75
+ reason: 'Relative paths required for self-contained HTML',
76
+ },
77
+ ];
78
+
79
+ /**
80
+ * Virtual module prefix for generated app entries.
81
+ */
82
+ const VIRTUAL_PREFIX = 'virtual:mcp-app/';
83
+ const RESOLVED_VIRTUAL_PREFIX = `\0${VIRTUAL_PREFIX}`;
84
+
85
+ /**
86
+ * The MCP App runtime is now handled by the ext-apps protocol
87
+ * via `@modelcontextprotocol/ext-apps` React hooks bundled
88
+ * into the app's JavaScript. No injected script is needed.
89
+ *
90
+ * Previously, this was a <script> tag that listened for
91
+ * `postMessage({ props })` from the host. The ext-apps protocol
92
+ * uses JSON-RPC 2.0 messages instead (ui/initialize, tool-result, etc.)
93
+ */
94
+
95
+ /**
96
+ * Get a nested property value from an object using dot notation.
97
+ */
98
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
99
+ return path.split('.').reduce<unknown>((current, key) => {
100
+ if (current && typeof current === 'object' && key in current) {
101
+ return (current as Record<string, unknown>)[key];
102
+ }
103
+ return undefined;
104
+ }, obj);
105
+ }
106
+
107
+ /**
108
+ * Convert a name to kebab-case for HTML file output.
109
+ */
110
+ function toKebabCase(name: string): string {
111
+ return name
112
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
113
+ .replace(/_/g, '-')
114
+ .toLowerCase();
115
+ }
116
+
117
+ /**
118
+ * Information about an app extracted from the config file.
119
+ */
120
+ interface AppInfo {
121
+ /** The app's name (from createApp config) */
122
+ name: string;
123
+ /** The exported variable name */
124
+ exportName: string;
125
+ /** The component identifier */
126
+ componentName: string;
127
+ /** The component's import path (relative or package) */
128
+ componentImportPath: string;
129
+ }
130
+
131
+ /**
132
+ * Find the apps config file in the project.
133
+ */
134
+ function findAppsConfigFile(projectRoot: string, customPath?: string): string | null {
135
+ if (customPath) {
136
+ const fullPath = path.resolve(projectRoot, customPath);
137
+ if (fs.existsSync(fullPath)) {
138
+ return fullPath;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ const candidates = [
144
+ 'src/mcp-apps.ts',
145
+ 'src/mcp-apps.tsx',
146
+ 'src/mcp/apps.ts',
147
+ 'src/mcp/apps.tsx',
148
+ ];
149
+
150
+ for (const candidate of candidates) {
151
+ const fullPath = path.resolve(projectRoot, candidate);
152
+ if (fs.existsSync(fullPath)) {
153
+ return fullPath;
154
+ }
155
+ }
156
+
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * Parse the apps config file to extract app definitions.
162
+ *
163
+ * Uses simple regex parsing to extract:
164
+ * - Import statements for components
165
+ * - createApp calls with name and component properties
166
+ */
167
+ function parseAppsConfig(configPath: string): AppInfo[] {
168
+ const content = fs.readFileSync(configPath, 'utf-8');
169
+ const apps: AppInfo[] = [];
170
+
171
+ // Parse imports to map component names to their paths
172
+ const importMap = new Map<string, string>();
173
+ const importRegex = /import\s+(?:{([^}]+)}|(\w+))\s+from\s+['"]([^'"]+)['"]/g;
174
+ let match: RegExpExecArray | null;
175
+
176
+ while ((match = importRegex.exec(content)) !== null) {
177
+ const importPath = match[3];
178
+ if (match[2]) {
179
+ // Default import: import Foo from './foo'
180
+ importMap.set(match[2], importPath);
181
+ } else if (match[1]) {
182
+ // Named imports: import { Foo, Bar as Baz } from './foo'
183
+ const namedImports = match[1].split(',').map((s) => s.trim());
184
+ for (const namedImport of namedImports) {
185
+ const parts = namedImport.split(/\s+as\s+/);
186
+ const localName = parts[1] || parts[0];
187
+ importMap.set(localName.trim(), importPath);
188
+ }
189
+ }
190
+ }
191
+
192
+ // Find createApp calls - match the start, then find balanced braces
193
+ const createAppStartRegex =
194
+ /export\s+(?:const|let|var)\s+(\w+)\s*=\s*createApp\s*\(\s*\{/g;
195
+
196
+ while ((match = createAppStartRegex.exec(content)) !== null) {
197
+ const exportName = match[1];
198
+ const startIndex = match.index + match[0].length - 1; // Position of opening {
199
+
200
+ // Find the matching closing brace using brace counting
201
+ let braceCount = 1;
202
+ let endIndex = startIndex + 1;
203
+
204
+ while (braceCount > 0 && endIndex < content.length) {
205
+ const char = content[endIndex];
206
+ if (char === '{') braceCount++;
207
+ else if (char === '}') braceCount--;
208
+ endIndex++;
209
+ }
210
+
211
+ if (braceCount !== 0) {
212
+ // Unbalanced braces, skip this match
213
+ continue;
214
+ }
215
+
216
+ // Extract the config body (between the braces)
217
+ const configBody = content.slice(startIndex + 1, endIndex - 1);
218
+
219
+ // Extract name property - look for the first name: '...' or name: "..."
220
+ const nameMatch = configBody.match(/name\s*:\s*['"]([^'"]+)['"]/);
221
+ if (!nameMatch) continue;
222
+ const name = nameMatch[1];
223
+
224
+ // Extract component property - look for component: <identifier>
225
+ const componentMatch = configBody.match(/component\s*:\s*(\w+)/);
226
+ if (!componentMatch) continue;
227
+ const componentName = componentMatch[1];
228
+
229
+ // Look up the component's import path
230
+ const componentImportPath = importMap.get(componentName);
231
+ if (!componentImportPath) {
232
+ console.warn(
233
+ `[mcp-web-app] Warning: Could not find import for component "${componentName}" in app "${name}"`
234
+ );
235
+ continue;
236
+ }
237
+
238
+ apps.push({
239
+ name,
240
+ exportName,
241
+ componentName,
242
+ componentImportPath,
243
+ });
244
+ }
245
+
246
+ return apps;
247
+ }
248
+
249
+ /**
250
+ * Resolve a component import path to an absolute path.
251
+ */
252
+ function resolveComponentPath(
253
+ componentImportPath: string,
254
+ configDir: string
255
+ ): string {
256
+ // If it starts with . or /, it's a relative/absolute path
257
+ if (
258
+ componentImportPath.startsWith('.') ||
259
+ componentImportPath.startsWith('/')
260
+ ) {
261
+ // Resolve relative to the config file's directory
262
+ const resolved = path.resolve(configDir, componentImportPath);
263
+
264
+ // Try with common extensions
265
+ const extensions = ['.tsx', '.ts', '.jsx', '.js'];
266
+ for (const ext of extensions) {
267
+ if (fs.existsSync(resolved + ext)) {
268
+ return resolved + ext;
269
+ }
270
+ }
271
+ if (fs.existsSync(resolved)) {
272
+ return resolved;
273
+ }
274
+ // Check for index files
275
+ for (const ext of extensions) {
276
+ const indexPath = path.join(resolved, `index${ext}`);
277
+ if (fs.existsSync(indexPath)) {
278
+ return indexPath;
279
+ }
280
+ }
281
+ return resolved;
282
+ }
283
+
284
+ // Otherwise, it's a package import - return as-is for Vite to resolve
285
+ return componentImportPath;
286
+ }
287
+
288
+ /**
289
+ * Generate a virtual module that renders an MCP App.
290
+ */
291
+ function generateVirtualModule(
292
+ componentPath: string,
293
+ componentName: string
294
+ ): string {
295
+ return `
296
+ import { renderMCPApp } from '@mcp-web/app/internal';
297
+ import { ${componentName} } from '${componentPath}';
298
+
299
+ renderMCPApp(${componentName});
300
+ `;
301
+ }
302
+
303
+ /**
304
+ * Generate HTML content for an app entry.
305
+ */
306
+ function generateHTMLContent(appName: string, virtualModulePath: string): string {
307
+ return `<!DOCTYPE html>
308
+ <html lang="en" style="color-scheme: light dark">
309
+ <head>
310
+ <meta charset="UTF-8" />
311
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
312
+ <title>${appName}</title>
313
+ <style>body { margin: 0; background: transparent; }</style>
314
+ </head>
315
+ <body>
316
+ <div id="root"></div>
317
+ <script type="module" src="${virtualModulePath}"></script>
318
+ </body>
319
+ </html>`;
320
+ }
321
+
322
+ /**
323
+ * Internal plugin that handles virtual modules and build process.
324
+ */
325
+ function mcpAppPlugin(
326
+ projectRoot: string,
327
+ outDir: string,
328
+ configPath: string | null
329
+ ): Plugin {
330
+ let apps: AppInfo[] = [];
331
+ let configDir: string;
332
+ const virtualModules = new Map<string, string>();
333
+ const htmlEntries = new Map<string, string>();
334
+
335
+ return {
336
+ name: 'mcp-web-app',
337
+
338
+ configResolved(_config) {
339
+ // Store config for potential future use
340
+ },
341
+
342
+ buildStart() {
343
+ // Clear previous state
344
+ virtualModules.clear();
345
+ htmlEntries.clear();
346
+
347
+ if (!configPath) {
348
+ console.log(
349
+ '\n\u26A0 No MCP Apps config file found. Looking for src/mcp-apps.ts or src/mcp/apps.ts'
350
+ );
351
+ return;
352
+ }
353
+
354
+ configDir = path.dirname(configPath);
355
+ apps = parseAppsConfig(configPath);
356
+
357
+ if (apps.length === 0) {
358
+ console.log(
359
+ `\n\u26A0 No apps found in ${path.relative(projectRoot, configPath)}`
360
+ );
361
+ return;
362
+ }
363
+
364
+ // Generate virtual modules for each app
365
+ for (const app of apps) {
366
+ const kebabName = toKebabCase(app.name);
367
+ const virtualId = `${VIRTUAL_PREFIX}${kebabName}`;
368
+ const resolvedVirtualId = `${RESOLVED_VIRTUAL_PREFIX}${kebabName}`;
369
+
370
+ // Resolve the component path
371
+ const absoluteComponentPath = resolveComponentPath(
372
+ app.componentImportPath,
373
+ configDir
374
+ );
375
+
376
+ // Generate the virtual module content
377
+ const moduleContent = generateVirtualModule(
378
+ absoluteComponentPath,
379
+ app.componentName
380
+ );
381
+ virtualModules.set(resolvedVirtualId, moduleContent);
382
+
383
+ // Generate HTML entry
384
+ const htmlContent = generateHTMLContent(kebabName, virtualId);
385
+ const htmlPath = path.join(outDir, `${kebabName}.html`);
386
+ htmlEntries.set(kebabName, htmlContent);
387
+
388
+ // Write temporary HTML file for Vite to use as entry
389
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
390
+ }
391
+
392
+ console.log(
393
+ `\n\u2139 Found ${apps.length} MCP App(s) in ${path.relative(projectRoot, configPath)}`
394
+ );
395
+ },
396
+
397
+ resolveId(id) {
398
+ if (id.startsWith(VIRTUAL_PREFIX)) {
399
+ return `\0${id}`;
400
+ }
401
+ return null;
402
+ },
403
+
404
+ load(id) {
405
+ // Handle virtual app modules
406
+ if (id.startsWith(RESOLVED_VIRTUAL_PREFIX)) {
407
+ return virtualModules.get(id) || null;
408
+ }
409
+
410
+ return null;
411
+ },
412
+
413
+ generateBundle() {
414
+ // Don't emit additional HTML - let vite-plugin-singlefile handle the output
415
+ // The inlined HTML will be at a nested path which we'll fix in closeBundle
416
+ },
417
+
418
+ closeBundle() {
419
+ if (apps.length === 0) return;
420
+
421
+ // The inlined HTML files are output to a nested path matching the temp directory structure
422
+ // Move them to the root of the output directory
423
+ const tempSubDir = path.join(outDir, 'node_modules', '.mcp-web-app-temp');
424
+
425
+ if (fs.existsSync(tempSubDir)) {
426
+ for (const app of apps) {
427
+ const kebabName = toKebabCase(app.name);
428
+ const srcPath = path.join(tempSubDir, `${kebabName}.html`);
429
+ const destPath = path.join(outDir, `${kebabName}.html`);
430
+
431
+ if (fs.existsSync(srcPath)) {
432
+ // Move the inlined HTML to the root
433
+ fs.copyFileSync(srcPath, destPath);
434
+ }
435
+ }
436
+
437
+ // Clean up the nested directory
438
+ fs.rmSync(path.join(outDir, 'node_modules'), { recursive: true, force: true });
439
+ }
440
+
441
+ const relOutDir = path.relative(projectRoot, outDir);
442
+ console.log(`\n\u2713 Built ${apps.length} MCP App(s) to ${relOutDir}/`);
443
+ for (const app of apps) {
444
+ const kebabName = toKebabCase(app.name);
445
+ console.log(` - ${kebabName}.html (${app.exportName})`);
446
+ }
447
+ },
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Define a Vite configuration for building MCP Apps as single HTML files.
453
+ *
454
+ * This function creates a complete Vite config that auto-discovers app
455
+ * definitions and bundles them into self-contained HTML files. These files
456
+ * can be served as MCP App resources that render inline in AI chat interfaces.
457
+ *
458
+ * **How it works:**
459
+ * 1. Scans for a config file (`src/mcp-apps.ts` or `src/mcp/apps.ts`)
460
+ * 2. Parses `createApp()` calls to find app names and components
461
+ * 3. Auto-generates entry files that render each component
462
+ * 4. Bundles everything into single HTML files
463
+ *
464
+ * **Features:**
465
+ * - No manual entry files needed - just define your apps!
466
+ * - Full Vite config flexibility - pass any standard Vite options
467
+ * - Automatic single-file bundling (JS, CSS, assets all inlined)
468
+ * - Watch mode support for development
469
+ * - PostMessage runtime for receiving props from the host
470
+ *
471
+ * **Critical settings that are always enforced:**
472
+ * - `build.assetsInlineLimit` → Maximum (for inlining)
473
+ * - `build.cssCodeSplit` → false (single CSS bundle)
474
+ * - `base` → './' (relative paths)
475
+ *
476
+ * @param viteConfig - Standard Vite configuration options (plugins, build settings, etc.)
477
+ * @param mcpOptions - MCP-specific options (appsConfig, outDir, silenceOverrideWarnings)
478
+ * @returns A Vite UserConfigExport ready for use in vite.config.ts
479
+ *
480
+ * @example Basic Usage
481
+ * ```typescript
482
+ * // vite.apps.config.ts
483
+ * import react from '@vitejs/plugin-react';
484
+ * import { defineMCPAppsConfig } from '@mcp-web/app/vite';
485
+ *
486
+ * export default defineMCPAppsConfig({
487
+ * plugins: [react()],
488
+ * });
489
+ * ```
490
+ *
491
+ * @example With Custom Options
492
+ * ```typescript
493
+ * import react from '@vitejs/plugin-react';
494
+ * import { defineMCPAppsConfig } from '@mcp-web/app/vite';
495
+ *
496
+ * export default defineMCPAppsConfig(
497
+ * {
498
+ * plugins: [react()],
499
+ * build: {
500
+ * sourcemap: true,
501
+ * minify: 'terser',
502
+ * },
503
+ * },
504
+ * {
505
+ * appsConfig: 'src/my-apps.ts',
506
+ * outDir: 'dist/apps',
507
+ * }
508
+ * );
509
+ * ```
510
+ *
511
+ * @example Apps Config File (src/mcp-apps.ts)
512
+ * ```typescript
513
+ * import { createApp } from '@mcp-web/app';
514
+ * import { Statistics } from './components/Statistics';
515
+ *
516
+ * export const statisticsApp = createApp({
517
+ * name: 'show_statistics',
518
+ * description: 'Display statistics visualization',
519
+ * component: Statistics,
520
+ * handler: () => ({
521
+ * completionRate: 0.75,
522
+ * totalTasks: 100,
523
+ * }),
524
+ * });
525
+ * ```
526
+ */
527
+ export function defineMCPAppsConfig(
528
+ viteConfig: UserConfig = {},
529
+ mcpOptions: MCPAppOptions = {}
530
+ ): UserConfigExport {
531
+ const {
532
+ appsConfig,
533
+ outDir = 'public/mcp-web-apps',
534
+ silenceOverrideWarnings = false,
535
+ } = mcpOptions;
536
+
537
+ const projectRoot = process.cwd();
538
+ const outDirPath = path.resolve(projectRoot, outDir);
539
+
540
+ // Find the apps config file
541
+ const configPath = findAppsConfigFile(projectRoot, appsConfig);
542
+
543
+ // Parse apps to generate rollup inputs
544
+ let apps: AppInfo[] = [];
545
+ let tempHtmlDir: string | null = null;
546
+ const input: Record<string, string> = {};
547
+
548
+ if (configPath) {
549
+ apps = parseAppsConfig(configPath);
550
+
551
+ if (apps.length > 0) {
552
+ // Create a temp directory for HTML entries
553
+ tempHtmlDir = path.join(projectRoot, 'node_modules', '.mcp-web-app-temp');
554
+ fs.mkdirSync(tempHtmlDir, { recursive: true });
555
+
556
+ // Generate HTML entries for rollup input
557
+ for (const app of apps) {
558
+ const kebabName = toKebabCase(app.name);
559
+ const htmlPath = path.join(tempHtmlDir, `${kebabName}.html`);
560
+ const virtualModulePath = `${VIRTUAL_PREFIX}${kebabName}`;
561
+
562
+ const htmlContent = generateHTMLContent(kebabName, virtualModulePath);
563
+ fs.writeFileSync(htmlPath, htmlContent);
564
+
565
+ input[kebabName] = htmlPath;
566
+ }
567
+ }
568
+ }
569
+
570
+ // Check for and warn about overridden settings
571
+ if (!silenceOverrideWarnings) {
572
+ const userConfig = viteConfig as Record<string, unknown>;
573
+ for (const override of CRITICAL_OVERRIDES) {
574
+ const userValue = getNestedValue(userConfig, override.key);
575
+ if (userValue !== undefined && userValue !== override.required) {
576
+ console.warn(
577
+ `[mcp-web-app] Warning: Overriding ${override.key} ` +
578
+ `(was: ${JSON.stringify(userValue)}, now: ${JSON.stringify(override.required)}). ` +
579
+ `Reason: ${override.reason}`
580
+ );
581
+ }
582
+ }
583
+ }
584
+
585
+ // Build the merged config
586
+ const mergedConfig: UserConfig = {
587
+ ...viteConfig,
588
+
589
+ // These are always set by MCP Apps
590
+ base: './',
591
+
592
+ plugins: [
593
+ // User plugins first
594
+ ...(viteConfig.plugins || []),
595
+ // Then our plugins
596
+ viteSingleFile(),
597
+ mcpAppPlugin(projectRoot, outDirPath, configPath),
598
+ ],
599
+
600
+ build: {
601
+ ...viteConfig.build,
602
+
603
+ outDir: outDirPath,
604
+ emptyOutDir: true,
605
+
606
+ // Critical overrides for single-file output
607
+ assetsInlineLimit: Number.MAX_SAFE_INTEGER,
608
+ cssCodeSplit: false,
609
+
610
+ rollupOptions: {
611
+ ...viteConfig.build?.rollupOptions,
612
+ input: Object.keys(input).length > 0 ? input : undefined,
613
+ },
614
+ },
615
+ };
616
+
617
+ return mergedConfig;
618
+ }
619
+
620
+ export default defineMCPAppsConfig;
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "skipLibCheck": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+
11
+ /* JSX support */
12
+ "jsx": "react-jsx",
13
+
14
+ /* Module resolution */
15
+ "moduleResolution": "bundler",
16
+ "esModuleInterop": true,
17
+ "allowSyntheticDefaultImports": true,
18
+ "moduleDetection": "force",
19
+
20
+ /* Declaration files */
21
+ "declaration": true,
22
+ "declarationMap": true,
23
+
24
+ /* Linting */
25
+ "strict": true,
26
+ "noUnusedLocals": true,
27
+ "noUnusedParameters": true,
28
+ "noFallthroughCasesInSwitch": true
29
+ },
30
+ "include": ["src/**/*"],
31
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
32
+ }