@bleedingdev/modern-js-mcp-apps 0.0.0-stage → 3.11.3-ultramodern.1
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 +21 -0
- package/README.md +68 -2
- package/dist/cjs/cli.cjs +471 -0
- package/dist/cjs/server-plugin.cjs +79 -0
- package/dist/cjs/server.cjs +317 -0
- package/dist/esm/mf-entry.mjs +75 -0
- package/dist/esm/react.mjs +80 -0
- package/dist/esm-node/3~0.mjs +81 -0
- package/dist/esm-node/cli.mjs +360 -0
- package/dist/esm-node/server-plugin.mjs +31 -0
- package/dist/esm-node/server.mjs +216 -0
- package/dist/types/cli/build.d.ts +23 -0
- package/dist/types/cli/index.d.ts +16 -0
- package/dist/types/cli/server-plugin.d.ts +6 -0
- package/dist/types/manifest.d.ts +38 -0
- package/dist/types/server/context.d.ts +2 -0
- package/dist/types/server/index.d.ts +34 -0
- package/dist/types/server/resources.d.ts +12 -0
- package/dist/types/ui/mf-entry.d.ts +1 -0
- package/dist/types/ui/mf.d.ts +10 -0
- package/dist/types/ui/react.d.ts +25 -0
- package/dist/types/ui/state.d.ts +13 -0
- package/package.json +109 -4
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import promises from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import node_path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRsbuild } from "@rsbuild/core";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { validateUiMetadata, validateViewsConfig } from "./3~0.mjs";
|
|
8
|
+
function assetPrefixOrigin(prefix) {
|
|
9
|
+
if ('string' != typeof prefix) return;
|
|
10
|
+
if (prefix.startsWith('//')) return new URL(`https:${prefix}`).origin;
|
|
11
|
+
if (/^https?:\/\//.test(prefix)) return new URL(prefix).origin;
|
|
12
|
+
}
|
|
13
|
+
function mergeCsp(...sources) {
|
|
14
|
+
const merged = {};
|
|
15
|
+
for (const source of sources)for (const [key, domains] of Object.entries(source ?? {}))merged[key] = [
|
|
16
|
+
...new Set([
|
|
17
|
+
...merged[key] ?? [],
|
|
18
|
+
...domains ?? []
|
|
19
|
+
])
|
|
20
|
+
];
|
|
21
|
+
return Object.keys(merged).length ? merged : void 0;
|
|
22
|
+
}
|
|
23
|
+
function viewEntryName(name) {
|
|
24
|
+
const label = name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').slice(0, 48).replace(/^-+|-+$/g, '') || 'view';
|
|
25
|
+
const hash = createHash('sha256').update(name).digest('hex').slice(0, 12);
|
|
26
|
+
return `mcp-ui-${label}-${hash}`;
|
|
27
|
+
}
|
|
28
|
+
function findMfManifest(assets, manifestFile) {
|
|
29
|
+
const matches = assets.filter((asset)=>node_path.posix.basename(asset.name) === manifestFile);
|
|
30
|
+
if (1 !== matches.length) throw new Error(`Expected one MF manifest named ${manifestFile} in the web compilation, found ${matches.length}${matches.length ? `: ${matches.map((asset)=>asset.name).join(', ')}` : ''}. Set mf.manifestFile to the manifest filename`);
|
|
31
|
+
const [asset] = matches;
|
|
32
|
+
const manifest = JSON.parse(String(asset.source.source()));
|
|
33
|
+
if (!manifest || 'string' != typeof manifest.name || !Array.isArray(manifest.exposes)) throw new Error(`Invalid MF manifest: ${asset.name}`);
|
|
34
|
+
return {
|
|
35
|
+
filename: asset.name,
|
|
36
|
+
manifest: manifest
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function createUiManifest(config, prefix, mf, csp) {
|
|
40
|
+
validateViewsConfig(config);
|
|
41
|
+
if (void 0 !== csp) validateUiMetadata({
|
|
42
|
+
csp
|
|
43
|
+
});
|
|
44
|
+
const { views, remote: defaultRemote, ...metadata } = config;
|
|
45
|
+
const assetOrigin = assetPrefixOrigin(prefix);
|
|
46
|
+
const mergedCsp = mergeCsp(metadata.csp, csp, assetOrigin ? {
|
|
47
|
+
resourceDomains: [
|
|
48
|
+
assetOrigin
|
|
49
|
+
],
|
|
50
|
+
connectDomains: [
|
|
51
|
+
assetOrigin
|
|
52
|
+
]
|
|
53
|
+
} : void 0);
|
|
54
|
+
const ui = {
|
|
55
|
+
prefersBorder: true,
|
|
56
|
+
...metadata,
|
|
57
|
+
...mergedCsp ? {
|
|
58
|
+
csp: mergedCsp
|
|
59
|
+
} : {}
|
|
60
|
+
};
|
|
61
|
+
const manifest = {
|
|
62
|
+
version: 1,
|
|
63
|
+
assetBase: 'string' == typeof prefix && /^https?:\/\//.test(prefix) ? `${prefix.replace(/\/+$/, '')}/` : 'request',
|
|
64
|
+
remotes: Object.create(null),
|
|
65
|
+
views: Object.create(null)
|
|
66
|
+
};
|
|
67
|
+
for (const [name, source] of Object.entries(views)){
|
|
68
|
+
if ('string' == typeof source) {
|
|
69
|
+
manifest.views[name] = {
|
|
70
|
+
html: `./ui/${viewEntryName(name)}.html`,
|
|
71
|
+
ui
|
|
72
|
+
};
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let remote;
|
|
76
|
+
let entry;
|
|
77
|
+
const deployedRemote = source.remote ?? defaultRemote;
|
|
78
|
+
if (void 0 !== deployedRemote) {
|
|
79
|
+
entry = deployedRemote;
|
|
80
|
+
remote = `mcp-remote-${createHash('sha256').update(entry).digest('hex').slice(0, 12)}`;
|
|
81
|
+
} else {
|
|
82
|
+
if (!mf) throw new Error(`View ${name} requires a Module Federation manifest. Enable the MF plugin and manifest output`);
|
|
83
|
+
if (!mf.manifest.exposes.some((item)=>item.name.replace(/^\.\//, '') === source.expose.slice(2))) throw new Error(`View ${name} references missing MF expose: ${source.expose}`);
|
|
84
|
+
const assetPath = 'string' == typeof prefix && prefix.startsWith('/') && !prefix.startsWith('//') ? prefix.replace(/^\/+|\/+$/g, '') : '';
|
|
85
|
+
entry = [
|
|
86
|
+
assetPath,
|
|
87
|
+
mf.filename
|
|
88
|
+
].filter(Boolean).join('/');
|
|
89
|
+
remote = mf.manifest.name;
|
|
90
|
+
}
|
|
91
|
+
manifest.remotes[remote] = {
|
|
92
|
+
entry,
|
|
93
|
+
shell: './ui/mcp-shell.html'
|
|
94
|
+
};
|
|
95
|
+
manifest.views[name] = {
|
|
96
|
+
remote,
|
|
97
|
+
expose: source.expose,
|
|
98
|
+
ui
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return manifest;
|
|
102
|
+
}
|
|
103
|
+
async function writeArtifact(filename, contents) {
|
|
104
|
+
await promises.mkdir(node_path.dirname(filename), {
|
|
105
|
+
recursive: true
|
|
106
|
+
});
|
|
107
|
+
await promises.writeFile(`${filename}.tmp`, contents);
|
|
108
|
+
await promises.rename(`${filename}.tmp`, filename);
|
|
109
|
+
}
|
|
110
|
+
const packageFile = (filename)=>node_path.resolve(node_path.dirname(fileURLToPath(import.meta.url)), '../..', 'dist/esm', filename);
|
|
111
|
+
const mcpAppsPlugin = (options = {})=>({
|
|
112
|
+
name: '@modern-js/mcp-apps',
|
|
113
|
+
post: [
|
|
114
|
+
'@modern-js/runtime'
|
|
115
|
+
],
|
|
116
|
+
setup (api) {
|
|
117
|
+
api._internalServerPlugins(({ plugins })=>{
|
|
118
|
+
const ctx = api.getAppContext();
|
|
119
|
+
plugins.push({
|
|
120
|
+
name: '@modern-js/mcp-apps/server-plugin',
|
|
121
|
+
options: [
|
|
122
|
+
'dev',
|
|
123
|
+
'start'
|
|
124
|
+
].includes(ctx.command) ? {
|
|
125
|
+
developmentManifest: node_path.join(ctx.internalDirectory, 'mcp-apps/ui-manifest.json')
|
|
126
|
+
} : {}
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
plugins
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
let definition;
|
|
133
|
+
const attachedCompilers = new WeakSet();
|
|
134
|
+
const shellHtml = new Map();
|
|
135
|
+
const shellAssets = new Map();
|
|
136
|
+
let buildShell;
|
|
137
|
+
let shellDirty = true;
|
|
138
|
+
let shellBuild;
|
|
139
|
+
const localEntries = new Set();
|
|
140
|
+
const configPath = ()=>node_path.resolve(api.getAppContext().appDirectory, options.views ?? 'mcp-views.json');
|
|
141
|
+
api.addWatchFiles(()=>[
|
|
142
|
+
configPath()
|
|
143
|
+
]);
|
|
144
|
+
api.modifyEntrypoints(async ({ entrypoints: current })=>{
|
|
145
|
+
const ctx = api.getAppContext();
|
|
146
|
+
const source = JSON.parse(await promises.readFile(configPath(), 'utf8'));
|
|
147
|
+
validateViewsConfig(source);
|
|
148
|
+
definition = source;
|
|
149
|
+
const appRequire = createRequire(node_path.join(ctx.appDirectory, 'package.json'));
|
|
150
|
+
const needsMf = Object.values(definition.views).some((view)=>'string' != typeof view);
|
|
151
|
+
localEntries.clear();
|
|
152
|
+
shellHtml.clear();
|
|
153
|
+
shellAssets.clear();
|
|
154
|
+
buildShell = void 0;
|
|
155
|
+
shellDirty = true;
|
|
156
|
+
const shellEntries = {};
|
|
157
|
+
const entryDir = node_path.join(ctx.internalDirectory, 'mcp-apps/entries');
|
|
158
|
+
const entrypoints = current.filter((entry)=>entry.absoluteEntryDir !== entryDir);
|
|
159
|
+
const { enableAsyncEntry } = api.getNormalizedConfig().source;
|
|
160
|
+
for (const [name, source] of Object.entries(definition.views)){
|
|
161
|
+
if ('string' != typeof source) continue;
|
|
162
|
+
const entryName = viewEntryName(name);
|
|
163
|
+
if (entrypoints.some((entry)=>entry.entryName === entryName)) throw new Error(`MCP entry conflicts with application entry: ${entryName}`);
|
|
164
|
+
const entry = node_path.join(entryDir, `${entryName}.jsx`);
|
|
165
|
+
const mountEntry = enableAsyncEntry ? node_path.join(entryDir, `${entryName}-mount.jsx`) : entry;
|
|
166
|
+
await writeArtifact(mountEntry, `import React from ${JSON.stringify(appRequire.resolve('react'))};\nimport { createRoot } from ${JSON.stringify(appRequire.resolve('react-dom/client'))};\nimport { createMcpView } from ${JSON.stringify(packageFile('react.mjs'))};\nimport View, * as viewModule from ${JSON.stringify(node_path.resolve(node_path.dirname(configPath()), source))};\ncreateRoot(document.getElementById('root')).render(React.createElement(createMcpView(View, ${JSON.stringify({
|
|
167
|
+
name,
|
|
168
|
+
version: '1.0.0'
|
|
169
|
+
})}, Reflect.get(viewModule, 'mcpCapabilities'))));\n`);
|
|
170
|
+
if (enableAsyncEntry) await writeArtifact(entry, `import(${JSON.stringify(mountEntry)});\n`);
|
|
171
|
+
localEntries.add(entryName);
|
|
172
|
+
entrypoints.push({
|
|
173
|
+
entryName,
|
|
174
|
+
entry,
|
|
175
|
+
isMainEntry: false,
|
|
176
|
+
isAutoMount: false,
|
|
177
|
+
absoluteEntryDir: entryDir
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (needsMf) shellEntries['mcp-shell'] = packageFile('mf-entry.mjs');
|
|
181
|
+
if (Object.keys(shellEntries).length) {
|
|
182
|
+
const shellOutput = node_path.join(ctx.internalDirectory, 'mcp-apps/shell');
|
|
183
|
+
const config = api.getNormalizedConfig();
|
|
184
|
+
const development = [
|
|
185
|
+
'dev',
|
|
186
|
+
'start'
|
|
187
|
+
].includes(ctx.command);
|
|
188
|
+
const assetPrefix = development ? config.dev.assetPrefix : config.output.assetPrefix;
|
|
189
|
+
const shell = await createRsbuild({
|
|
190
|
+
cwd: ctx.appDirectory,
|
|
191
|
+
rsbuildConfig: {
|
|
192
|
+
mode: 'production',
|
|
193
|
+
html: {
|
|
194
|
+
inject: 'body',
|
|
195
|
+
title: 'MCP App'
|
|
196
|
+
},
|
|
197
|
+
source: {
|
|
198
|
+
entry: shellEntries,
|
|
199
|
+
define: {
|
|
200
|
+
...config.source.define,
|
|
201
|
+
'process.env.NODE_ENV': JSON.stringify(development ? 'development' : 'production')
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
resolve: {
|
|
205
|
+
alias: {
|
|
206
|
+
...config.source.alias,
|
|
207
|
+
...Object.fromEntries([
|
|
208
|
+
'react',
|
|
209
|
+
'react-dom',
|
|
210
|
+
'react-dom/client',
|
|
211
|
+
'react/jsx-runtime',
|
|
212
|
+
'react/jsx-dev-runtime'
|
|
213
|
+
].map((name)=>[
|
|
214
|
+
name + '$',
|
|
215
|
+
appRequire.resolve(name)
|
|
216
|
+
])),
|
|
217
|
+
...needsMf ? {
|
|
218
|
+
'@module-federation/enhanced/runtime$': appRequire.resolve(options.mf?.runtime ?? '@module-federation/enhanced/runtime')
|
|
219
|
+
} : {}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
output: {
|
|
223
|
+
distPath: {
|
|
224
|
+
root: shellOutput
|
|
225
|
+
},
|
|
226
|
+
assetPrefix: 'string' == typeof assetPrefix ? assetPrefix : void 0,
|
|
227
|
+
inlineScripts: true,
|
|
228
|
+
inlineStyles: true,
|
|
229
|
+
legalComments: 'inline'
|
|
230
|
+
},
|
|
231
|
+
tools: {
|
|
232
|
+
swc: {
|
|
233
|
+
jsc: {
|
|
234
|
+
transform: {
|
|
235
|
+
react: {
|
|
236
|
+
runtime: 'automatic'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
rspack: {
|
|
242
|
+
optimization: {
|
|
243
|
+
splitChunks: false,
|
|
244
|
+
nodeEnv: false
|
|
245
|
+
},
|
|
246
|
+
module: {
|
|
247
|
+
parser: {
|
|
248
|
+
javascript: {
|
|
249
|
+
dynamicImportMode: 'eager'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const compileShell = async ()=>{
|
|
258
|
+
const result = await shell.build();
|
|
259
|
+
await result.close();
|
|
260
|
+
shellAssets.clear();
|
|
261
|
+
for (const filename of (await promises.readdir(shellOutput, {
|
|
262
|
+
recursive: true
|
|
263
|
+
}))){
|
|
264
|
+
const absolute = node_path.join(shellOutput, filename);
|
|
265
|
+
if (!filename.endsWith('.html') && (await promises.stat(absolute)).isFile()) shellAssets.set(filename.replaceAll(node_path.sep, '/'), await promises.readFile(absolute));
|
|
266
|
+
}
|
|
267
|
+
for (const name of Object.keys(shellEntries))shellHtml.set(name, await promises.readFile(node_path.join(shellOutput, `${name}.html`), 'utf8'));
|
|
268
|
+
};
|
|
269
|
+
buildShell = ()=>{
|
|
270
|
+
if (shellBuild) return shellBuild;
|
|
271
|
+
if (!shellDirty) return Promise.resolve();
|
|
272
|
+
shellDirty = false;
|
|
273
|
+
shellBuild = compileShell().catch((error)=>{
|
|
274
|
+
shellDirty = true;
|
|
275
|
+
throw error;
|
|
276
|
+
}).finally(()=>{
|
|
277
|
+
shellBuild = void 0;
|
|
278
|
+
});
|
|
279
|
+
return shellBuild;
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
entrypoints
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
api.modifyRsbuildConfig((config)=>{
|
|
287
|
+
const lazy = config.dev?.lazyCompilation;
|
|
288
|
+
if (!lazy) return config;
|
|
289
|
+
const current = 'object' == typeof lazy ? lazy : {};
|
|
290
|
+
const test = current.test;
|
|
291
|
+
const internal = api.getAppContext().internalDirectory;
|
|
292
|
+
const eager = [
|
|
293
|
+
node_path.join(internal, 'mcp-apps/entries') + node_path.sep
|
|
294
|
+
];
|
|
295
|
+
config.dev = {
|
|
296
|
+
...config.dev,
|
|
297
|
+
lazyCompilation: {
|
|
298
|
+
...current,
|
|
299
|
+
test (module) {
|
|
300
|
+
const resource = module.resource;
|
|
301
|
+
if (resource && eager.some((directory)=>resource.startsWith(directory))) return false;
|
|
302
|
+
return 'function' == typeof test ? test(module) : test instanceof RegExp ? test.test(resource ?? '') : true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
return config;
|
|
307
|
+
});
|
|
308
|
+
api.onAfterCreateCompiler(({ compiler, environments })=>{
|
|
309
|
+
api.onBeforeRestart(()=>new Promise((resolve, reject)=>compiler.close((error)=>error ? reject(error) : resolve())));
|
|
310
|
+
const compilers = 'compilers' in compiler ? compiler.compilers : [
|
|
311
|
+
compiler
|
|
312
|
+
];
|
|
313
|
+
for (const child of compilers){
|
|
314
|
+
if (attachedCompilers.has(child)) continue;
|
|
315
|
+
const environment = environments[child.name ?? 'client'];
|
|
316
|
+
if (environment && 'web' === environment.config.output.target) {
|
|
317
|
+
attachedCompilers.add(child);
|
|
318
|
+
child.hooks.invalid.tap('modern-mcp-card-invalidate', ()=>{
|
|
319
|
+
shellDirty = true;
|
|
320
|
+
});
|
|
321
|
+
child.hooks.beforeCompile.tapPromise('modern-mcp-card-build', async ()=>{
|
|
322
|
+
await buildShell?.();
|
|
323
|
+
});
|
|
324
|
+
child.hooks.thisCompilation.tap('modern-mcp-card-html', (compilation)=>{
|
|
325
|
+
compilation.hooks.processAssets.tap({
|
|
326
|
+
name: 'modern-mcp-card-html',
|
|
327
|
+
stage: child.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT
|
|
328
|
+
}, ()=>{
|
|
329
|
+
for (const [filename, contents] of shellAssets)if (!compilation.getAsset(filename)) compilation.emitAsset(filename, new child.webpack.sources.RawSource(contents));
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
child.hooks.afterEmit.tapPromise('modern-mcp-ui-manifest', async (compilation)=>{
|
|
333
|
+
if (compilation.errors.length) return;
|
|
334
|
+
const ctx = api.getAppContext();
|
|
335
|
+
const development = [
|
|
336
|
+
'dev',
|
|
337
|
+
'start'
|
|
338
|
+
].includes(ctx.command);
|
|
339
|
+
const output = node_path.join(development ? ctx.internalDirectory : ctx.distDirectory, 'mcp-apps');
|
|
340
|
+
let mf;
|
|
341
|
+
if (Object.values(definition.views).some((view)=>'string' != typeof view && void 0 === view.remote && void 0 === definition.remote)) mf = findMfManifest(compilation.getAssets(), options.mf?.manifestFile ?? 'mf-manifest.json');
|
|
342
|
+
const config = api.getNormalizedConfig();
|
|
343
|
+
const manifest = createUiManifest(definition, development ? config.dev.assetPrefix : config.output.assetPrefix, mf, options.csp);
|
|
344
|
+
for (const name of localEntries){
|
|
345
|
+
const htmlPath = environment.htmlPaths[name];
|
|
346
|
+
const html = htmlPath && compilation.getAsset(htmlPath);
|
|
347
|
+
if (!html) throw new Error(`Missing MCP HTML output: ${name}`);
|
|
348
|
+
await writeArtifact(node_path.join(output, 'ui', `${name}.html`), html.source.source());
|
|
349
|
+
}
|
|
350
|
+
for (const [name, html] of shellHtml)await writeArtifact(node_path.join(output, 'ui', `${name}.html`), html);
|
|
351
|
+
await writeArtifact(node_path.join(output, 'ui-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
const cli = mcpAppsPlugin;
|
|
359
|
+
export default cli;
|
|
360
|
+
export { mcpAppsPlugin };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import node_path from "node:path";
|
|
2
|
+
const server_plugin = (options = {})=>({
|
|
3
|
+
name: '@modern-js/mcp-apps/server-plugin',
|
|
4
|
+
setup (api) {
|
|
5
|
+
api.onPrepare(()=>{
|
|
6
|
+
const { middlewares, distDirectory } = api.getServerContext();
|
|
7
|
+
const uiOptions = options.developmentManifest ? {
|
|
8
|
+
manifest: options.developmentManifest,
|
|
9
|
+
mode: 'development'
|
|
10
|
+
} : {
|
|
11
|
+
manifest: node_path.join(distDirectory, 'mcp-apps/ui-manifest.json'),
|
|
12
|
+
mode: 'production'
|
|
13
|
+
};
|
|
14
|
+
middlewares.push({
|
|
15
|
+
name: 'mcp-apps-context',
|
|
16
|
+
before: [
|
|
17
|
+
'bind-bff',
|
|
18
|
+
'hono-bff-api'
|
|
19
|
+
],
|
|
20
|
+
handler: async (context, next)=>{
|
|
21
|
+
context.set('locals', {
|
|
22
|
+
...context.get('locals'),
|
|
23
|
+
modernMcpApps: uiOptions
|
|
24
|
+
});
|
|
25
|
+
await next();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
export default server_plugin;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import node_path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { RESOURCE_MIME_TYPE, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
6
|
+
import { validateUiManifest, assertHttpUrl } from "./3~0.mjs";
|
|
7
|
+
const requestUiOptions = new WeakMap();
|
|
8
|
+
const manifestCache = new Map();
|
|
9
|
+
const remoteManifestTtl = 60000;
|
|
10
|
+
async function loadUiManifest(source, mode) {
|
|
11
|
+
const url = source instanceof URL ? new URL(source.href) : /^https?:\/\//.test(source) ? new URL(source) : pathToFileURL(node_path.resolve(source));
|
|
12
|
+
const production = 'production' === mode;
|
|
13
|
+
const key = `${production ? 'production' : 'development'}:${url.href}`;
|
|
14
|
+
let version = 'process';
|
|
15
|
+
if (!production && 'file:' === url.protocol) {
|
|
16
|
+
const info = await stat(fileURLToPath(url), {
|
|
17
|
+
bigint: true
|
|
18
|
+
});
|
|
19
|
+
version = `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
20
|
+
}
|
|
21
|
+
const cached = manifestCache.get(key);
|
|
22
|
+
if (cached?.version === version && cached.expiresAt > Date.now()) return cached.value;
|
|
23
|
+
const entry = {
|
|
24
|
+
version,
|
|
25
|
+
expiresAt: 1 / 0,
|
|
26
|
+
value: readSource(url).then((text)=>{
|
|
27
|
+
const manifest = JSON.parse(text);
|
|
28
|
+
validateUiManifest(manifest);
|
|
29
|
+
if (!production && 'file:' !== url.protocol) entry.expiresAt = Date.now() + remoteManifestTtl;
|
|
30
|
+
return {
|
|
31
|
+
manifest,
|
|
32
|
+
location: url
|
|
33
|
+
};
|
|
34
|
+
}).catch((error)=>{
|
|
35
|
+
if (manifestCache.get(key) === entry) manifestCache.delete(key);
|
|
36
|
+
throw error;
|
|
37
|
+
})
|
|
38
|
+
};
|
|
39
|
+
manifestCache.set(key, entry);
|
|
40
|
+
return entry.value;
|
|
41
|
+
}
|
|
42
|
+
async function readSource(url, signal) {
|
|
43
|
+
if ('file:' === url.protocol) return readFile(fileURLToPath(url), {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
signal
|
|
46
|
+
});
|
|
47
|
+
assertHttpUrl(url.href);
|
|
48
|
+
const response = await fetch(url, {
|
|
49
|
+
signal: signal ? AbortSignal.any([
|
|
50
|
+
signal,
|
|
51
|
+
AbortSignal.timeout(30000)
|
|
52
|
+
]) : AbortSignal.timeout(30000)
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) throw new Error(`Failed to read UI resource: HTTP ${response.status}`);
|
|
55
|
+
return response.text();
|
|
56
|
+
}
|
|
57
|
+
function publicBase(manifest, options) {
|
|
58
|
+
const configured = options.assetBase ?? manifest.assetBase;
|
|
59
|
+
if ('request' !== configured) {
|
|
60
|
+
const base = assertHttpUrl(configured);
|
|
61
|
+
if (!base.pathname.endsWith('/')) base.pathname += '/';
|
|
62
|
+
return base;
|
|
63
|
+
}
|
|
64
|
+
if (!options.request) throw new Error('Request-relative UI assets require a request or an explicit assetBase');
|
|
65
|
+
return new URL('/', requestOrigin(options.request));
|
|
66
|
+
}
|
|
67
|
+
function requestOrigin(request) {
|
|
68
|
+
const url = new URL(request.url);
|
|
69
|
+
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0].trim();
|
|
70
|
+
if ('https' === proto || 'http' === proto) url.protocol = `${proto}:`;
|
|
71
|
+
return url.origin;
|
|
72
|
+
}
|
|
73
|
+
function escapeAttribute(value) {
|
|
74
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
75
|
+
}
|
|
76
|
+
async function registerUiResources(server, options = {}) {
|
|
77
|
+
const injected = options.request && requestUiOptions.get(options.request);
|
|
78
|
+
options = {
|
|
79
|
+
...options,
|
|
80
|
+
manifest: options.manifest ?? injected?.manifest,
|
|
81
|
+
mode: options.mode ?? injected?.mode
|
|
82
|
+
};
|
|
83
|
+
if (!options.manifest) throw new Error('MCP UI manifest is required. Use the Modern.js MCP Apps server plugin with a request or pass an explicit manifest path or URL');
|
|
84
|
+
const { manifest, location } = await loadUiManifest(options.manifest, options.mode ?? 'production');
|
|
85
|
+
const base = publicBase(manifest, options);
|
|
86
|
+
const devSocket = 'development' === options.mode && options.request ? new URL(requestOrigin(options.request)) : void 0;
|
|
87
|
+
if (devSocket) devSocket.protocol = 'https:' === devSocket.protocol ? 'wss:' : 'ws:';
|
|
88
|
+
const uris = Object.create(null);
|
|
89
|
+
for (const [name, view] of Object.entries(manifest.views)){
|
|
90
|
+
const uri = `ui://app/${encodeURIComponent(name)}`;
|
|
91
|
+
uris[name] = uri;
|
|
92
|
+
const remote = 'remote' in view ? manifest.remotes[view.remote] : void 0;
|
|
93
|
+
const entry = remote ? new URL(remote.entry, base).href : void 0;
|
|
94
|
+
const ui = {
|
|
95
|
+
...view.ui,
|
|
96
|
+
csp: {
|
|
97
|
+
...view.ui.csp,
|
|
98
|
+
resourceDomains: [
|
|
99
|
+
...new Set([
|
|
100
|
+
...view.ui.csp?.resourceDomains ?? [],
|
|
101
|
+
base.origin,
|
|
102
|
+
...entry ? [
|
|
103
|
+
new URL(entry).origin
|
|
104
|
+
] : []
|
|
105
|
+
])
|
|
106
|
+
],
|
|
107
|
+
baseUriDomains: [
|
|
108
|
+
...new Set([
|
|
109
|
+
...view.ui.csp?.baseUriDomains ?? [],
|
|
110
|
+
base.origin
|
|
111
|
+
])
|
|
112
|
+
],
|
|
113
|
+
connectDomains: [
|
|
114
|
+
...new Set([
|
|
115
|
+
...view.ui.csp?.connectDomains ?? [],
|
|
116
|
+
base.origin,
|
|
117
|
+
...entry ? [
|
|
118
|
+
new URL(entry).origin
|
|
119
|
+
] : [],
|
|
120
|
+
...devSocket ? [
|
|
121
|
+
devSocket.origin
|
|
122
|
+
] : []
|
|
123
|
+
])
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
registerAppResource(server, name, uri, {
|
|
128
|
+
_meta: {
|
|
129
|
+
ui
|
|
130
|
+
}
|
|
131
|
+
}, async (_uri, extra)=>{
|
|
132
|
+
const source = 'html' in view ? view.html : remote.shell;
|
|
133
|
+
const sourceUrl = /^https?:\/\//.test(source) ? new URL(source) : new URL(source, location);
|
|
134
|
+
let html = await readSource(sourceUrl, options.request ? AbortSignal.any([
|
|
135
|
+
options.request.signal,
|
|
136
|
+
extra.mcpReq.signal
|
|
137
|
+
]) : extra.mcpReq.signal);
|
|
138
|
+
if (!/<head\b[^>]*>/i.test(html)) throw new Error('UI HTML requires a head element');
|
|
139
|
+
if (/<base\b/i.test(html)) throw new Error('UI HTML must not contain a base element');
|
|
140
|
+
const config = remote && 'expose' in view ? `<script type="application/json" id="modern-mcp-config">${JSON.stringify({
|
|
141
|
+
name: view.remote,
|
|
142
|
+
entry,
|
|
143
|
+
expose: view.expose,
|
|
144
|
+
view: name
|
|
145
|
+
}).replace(/</g, '\\u003c')}</script>` : '';
|
|
146
|
+
html = html.replace(/<head\b[^>]*>/i, (head)=>`${head}<base href="${escapeAttribute(base.href)}">${config}`);
|
|
147
|
+
return {
|
|
148
|
+
contents: [
|
|
149
|
+
{
|
|
150
|
+
uri,
|
|
151
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
152
|
+
text: html,
|
|
153
|
+
_meta: {
|
|
154
|
+
ui
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return uris;
|
|
162
|
+
}
|
|
163
|
+
async function currentContext(useContext) {
|
|
164
|
+
if (useContext) return useContext();
|
|
165
|
+
const { useHonoContext } = await import("@modern-js/server-runtime");
|
|
166
|
+
return useHonoContext();
|
|
167
|
+
}
|
|
168
|
+
function createFetchHandler(createServer, options = {}) {
|
|
169
|
+
const handler = createMcpHandler(({ requestInfo })=>{
|
|
170
|
+
if (!requestInfo) throw new Error('MCP HTTP request is required');
|
|
171
|
+
return createServer(requestInfo);
|
|
172
|
+
}, {
|
|
173
|
+
legacy: 'stateless',
|
|
174
|
+
onerror: options.onError
|
|
175
|
+
});
|
|
176
|
+
return async (request)=>{
|
|
177
|
+
const origin = request.headers.get('origin');
|
|
178
|
+
if (origin && ![
|
|
179
|
+
new URL(request.url).origin,
|
|
180
|
+
requestOrigin(request),
|
|
181
|
+
...options.allowedOrigins ?? []
|
|
182
|
+
].includes(origin)) return new Response('Forbidden origin', {
|
|
183
|
+
status: 403
|
|
184
|
+
});
|
|
185
|
+
return handler.fetch(request);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function createBffHandlers(createServer, options = {}) {
|
|
189
|
+
const { useContext, ...fetchOptions } = options;
|
|
190
|
+
const handle = createFetchHandler(async (request)=>{
|
|
191
|
+
const uiOptions = (await currentContext(useContext)).get('locals')?.modernMcpApps;
|
|
192
|
+
if (uiOptions) requestUiOptions.set(request, uiOptions);
|
|
193
|
+
return createServer(request);
|
|
194
|
+
}, fetchOptions);
|
|
195
|
+
const route = async (input)=>{
|
|
196
|
+
const context = await currentContext(useContext);
|
|
197
|
+
const raw = context.req.raw;
|
|
198
|
+
const headers = new Headers(raw.headers);
|
|
199
|
+
headers.delete('content-length');
|
|
200
|
+
const request = new Request(raw.url, {
|
|
201
|
+
method: raw.method,
|
|
202
|
+
headers,
|
|
203
|
+
...'GET' === raw.method || 'HEAD' === raw.method ? {} : {
|
|
204
|
+
body: void 0 === input.data ? '' : JSON.stringify(input.data)
|
|
205
|
+
},
|
|
206
|
+
signal: raw.signal
|
|
207
|
+
});
|
|
208
|
+
return handle(request);
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
POST: route,
|
|
212
|
+
GET: route,
|
|
213
|
+
DELETE: route
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export { createBffHandlers, createFetchHandler, registerUiResources };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type McpViewsConfig, type UiManifest, type UiMetadata } from '../manifest.js';
|
|
2
|
+
export type UiCsp = NonNullable<UiMetadata['csp']>;
|
|
3
|
+
export declare function viewEntryName(name: string): string;
|
|
4
|
+
export interface MfManifest {
|
|
5
|
+
name: string;
|
|
6
|
+
exposes: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/** Identify the MF manifest by filename. Stats files share its shape. */
|
|
11
|
+
export declare function findMfManifest(assets: ReadonlyArray<{
|
|
12
|
+
name: string;
|
|
13
|
+
source: {
|
|
14
|
+
source(): string | Buffer;
|
|
15
|
+
};
|
|
16
|
+
}>, manifestFile: string): {
|
|
17
|
+
filename: string;
|
|
18
|
+
manifest: MfManifest;
|
|
19
|
+
};
|
|
20
|
+
export declare function createUiManifest(config: McpViewsConfig, prefix: unknown, mf?: {
|
|
21
|
+
manifest: MfManifest;
|
|
22
|
+
filename: string;
|
|
23
|
+
}, csp?: UiCsp): UiManifest;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppTools, CliPlugin } from '@modern-js/app-tools';
|
|
2
|
+
import { type UiCsp } from './build.js';
|
|
3
|
+
export interface McpAppsPluginOptions {
|
|
4
|
+
/** JSON UI configuration, relative to the application root. */
|
|
5
|
+
views?: string;
|
|
6
|
+
/** CSP domains added to every view, merged with the csp in the views file. */
|
|
7
|
+
csp?: UiCsp;
|
|
8
|
+
mf?: {
|
|
9
|
+
/** MF manifest filename in the build output. Defaults to 'mf-manifest.json'. */
|
|
10
|
+
manifestFile?: string;
|
|
11
|
+
/** Module providing the shell's MF `createInstance`. Defaults to '@module-federation/enhanced/runtime'. */
|
|
12
|
+
runtime?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare const mcpAppsPlugin: (options?: McpAppsPluginOptions) => CliPlugin<AppTools>;
|
|
16
|
+
export default mcpAppsPlugin;
|