@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,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
|
+
}
|