@modern-js/mcp-apps 3.11.1 → 3.11.2

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/README.md CHANGED
@@ -25,7 +25,7 @@ Declare React cards in `mcp-views.json` and enable `mcpAppsPlugin({ views: './mc
25
25
  }
26
26
  ```
27
27
 
28
- Component paths resolve relative to the JSON file. Components must have a default export. MF exposes refer to this application's MF manifest output and must also have a default component export. Local cards are added as application entries and reuse the framework's multi-entry compilation, including its loaders, styles and build plugins. Only the MF shell uses a separate Rsbuild compilation with the application's React dependencies, with its startup scripts and styles inlined. The plugin emits `dist/mcp-apps/ui-manifest.json` and never registers HTTP routes.
28
+ Component paths resolve relative to the JSON file. Components must have a default export. MF exposes refer to this application's MF manifest output and must also have a default component export. `{ "remote": "https://host/mf-manifest.json", "expose": "./Name" }` refers to a deployed remote instead. Set `remote` at the top level alongside `csp` to share it across all expose views, for example `{ "remote": "https://host/mf-manifest.json", "views": { "todos": { "expose": "./Todos" }, "stats": { "expose": "./Stats" } } }`. A view-level `remote` overrides this default. Component paths remain local. Without either remote setting, exposes refer to this application. With a top-level `remote`, expose views in the same config cannot refer to this application's exposes, so set `remote` per view to mix both. The URL must point to the remote's manifest, with a path ending in `.json`, and the runtime reads the remote's name from it. The shell loads the remote manifest when the card opens, so republishing the remote needs no MCP server rebuild. Its exposes are not checked at build time, and the application needs no MF plugin. The manifest origin is added to CSP. Declare other origins serving the remote's chunks in `csp`. Local cards are added as application entries and reuse the framework's multi-entry compilation, including its loaders, styles and build plugins. Only the MF shell uses a separate Rsbuild compilation with the application's React dependencies, with its startup scripts and styles inlined. The plugin emits `dist/mcp-apps/ui-manifest.json` and never registers HTTP routes.
29
29
 
30
30
  - `mf.manifestFile` is the MF manifest filename in the web build output. It defaults to `mf-manifest.json` and must match exactly one emitted file.
31
31
  - `mf.runtime` is the module, resolved from the application, that the MF shell imports `createInstance` from. It defaults to `@module-federation/enhanced/runtime`.
package/dist/cjs/cli.cjs CHANGED
@@ -71,8 +71,13 @@ function validateUiMetadata(value) {
71
71
  function validateViewsConfig(value) {
72
72
  if (!isRecord(value) || !isRecord(value.views)) throw new Error('mcp-views.json requires a views object');
73
73
  validateUiMetadata(value);
74
+ if (void 0 !== value.remote) {
75
+ if ('string' != typeof value.remote) throw new Error('remote must be a string');
76
+ assertRemoteManifest(value.remote);
77
+ }
74
78
  for (const key of Object.keys(value))if (![
75
79
  'views',
80
+ 'remote',
76
81
  'csp',
77
82
  'prefersBorder',
78
83
  'permissions',
@@ -81,10 +86,28 @@ function validateViewsConfig(value) {
81
86
  for (const [name, view] of Object.entries(value.views)){
82
87
  if (!name.trim()) throw new Error('View names must be non-empty');
83
88
  if (!('string' == typeof view && view.trim())) {
84
- if (!(isRecord(view) && 'string' == typeof view.expose && view.expose.startsWith('./')) || 1 !== Object.keys(view).length) throw new Error(`Invalid view: ${name}. Use a component path or { expose: './Name' }`);
89
+ if (isRecord(view) && 'string' == typeof view.expose && view.expose.startsWith('./') && Object.keys(view).every((key)=>'expose' === key || 'remote' === key)) {
90
+ if (!('remote' in view)) continue;
91
+ if ('string' == typeof view.remote) {
92
+ assertRemoteManifest(view.remote);
93
+ continue;
94
+ }
95
+ }
96
+ throw new Error(`Invalid view: ${name}. Use a component path, { expose: './Name' } or { remote: 'https://host/mf-manifest.json', expose: './Name' }`);
85
97
  }
86
98
  }
87
99
  }
100
+ function assertRemoteManifest(remote) {
101
+ if (!/^https?:\/\//.test(remote) || !assertHttpUrl(remote).pathname.endsWith('.json')) throw new Error(`Invalid MF remote: ${remote}. Use https://host/mf-manifest.json`);
102
+ }
103
+ function assertHttpUrl(source) {
104
+ const url = new URL(source);
105
+ if (![
106
+ 'http:',
107
+ 'https:'
108
+ ].includes(url.protocol) || url.username || url.password) throw new Error(`Invalid UI URL: ${source}`);
109
+ return url;
110
+ }
88
111
  const external_node_crypto_namespaceObject = require("node:crypto");
89
112
  function assetPrefixOrigin(prefix) {
90
113
  if ('string' != typeof prefix) return;
@@ -122,7 +145,7 @@ function createUiManifest(config, prefix, mf, csp) {
122
145
  if (void 0 !== csp) validateUiMetadata({
123
146
  csp
124
147
  });
125
- const { views, ...metadata } = config;
148
+ const { views, remote: defaultRemote, ...metadata } = config;
126
149
  const assetOrigin = assetPrefixOrigin(prefix);
127
150
  const mergedCsp = mergeCsp(metadata.csp, csp, assetOrigin ? {
128
151
  resourceDomains: [
@@ -145,23 +168,36 @@ function createUiManifest(config, prefix, mf, csp) {
145
168
  remotes: Object.create(null),
146
169
  views: Object.create(null)
147
170
  };
148
- for (const [name, source] of Object.entries(views))if ('string' == typeof source) manifest.views[name] = {
149
- html: `./ui/${viewEntryName(name)}.html`,
150
- ui
151
- };
152
- else {
153
- if (!mf) throw new Error(`View ${name} requires a Module Federation manifest. Enable the MF plugin and manifest output`);
154
- if (!mf.manifest.exposes.some((item)=>item.name.replace(/^\.\//, '') === source.expose.slice(2))) throw new Error(`View ${name} references missing MF expose: ${source.expose}`);
155
- const assetPath = 'string' == typeof prefix && prefix.startsWith('/') && !prefix.startsWith('//') ? prefix.replace(/^\/+|\/+$/g, '') : '';
156
- manifest.remotes[mf.manifest.name] = {
157
- entry: [
171
+ for (const [name, source] of Object.entries(views)){
172
+ if ('string' == typeof source) {
173
+ manifest.views[name] = {
174
+ html: `./ui/${viewEntryName(name)}.html`,
175
+ ui
176
+ };
177
+ continue;
178
+ }
179
+ let remote;
180
+ let entry;
181
+ const deployedRemote = source.remote ?? defaultRemote;
182
+ if (void 0 !== deployedRemote) {
183
+ entry = deployedRemote;
184
+ remote = `mcp-remote-${(0, external_node_crypto_namespaceObject.createHash)('sha256').update(entry).digest('hex').slice(0, 12)}`;
185
+ } else {
186
+ if (!mf) throw new Error(`View ${name} requires a Module Federation manifest. Enable the MF plugin and manifest output`);
187
+ if (!mf.manifest.exposes.some((item)=>item.name.replace(/^\.\//, '') === source.expose.slice(2))) throw new Error(`View ${name} references missing MF expose: ${source.expose}`);
188
+ const assetPath = 'string' == typeof prefix && prefix.startsWith('/') && !prefix.startsWith('//') ? prefix.replace(/^\/+|\/+$/g, '') : '';
189
+ entry = [
158
190
  assetPath,
159
191
  mf.filename
160
- ].filter(Boolean).join('/'),
192
+ ].filter(Boolean).join('/');
193
+ remote = mf.manifest.name;
194
+ }
195
+ manifest.remotes[remote] = {
196
+ entry,
161
197
  shell: './ui/mcp-shell.html'
162
198
  };
163
199
  manifest.views[name] = {
164
- remote: mf.manifest.name,
200
+ remote,
165
201
  expose: source.expose,
166
202
  ui
167
203
  };
@@ -406,7 +442,7 @@ const mcpAppsPlugin = (options = {})=>({
406
442
  ].includes(ctx.command);
407
443
  const output = external_node_path_default().join(development ? ctx.internalDirectory : ctx.distDirectory, 'mcp-apps');
408
444
  let mf;
409
- if (Object.values(definition.views).some((view)=>'string' != typeof view)) mf = findMfManifest(compilation.getAssets(), options.mf?.manifestFile ?? 'mf-manifest.json');
445
+ 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');
410
446
  const config = api.getNormalizedConfig();
411
447
  const manifest = createUiManifest(definition, development ? config.dev.assetPrefix : config.output.assetPrefix, mf, options.csp);
412
448
  for (const name of localEntries){
@@ -231,7 +231,8 @@ async function registerUiResources(server, options = {}) {
231
231
  const config = remote && 'expose' in view ? `<script type="application/json" id="modern-mcp-config">${JSON.stringify({
232
232
  name: view.remote,
233
233
  entry,
234
- expose: view.expose
234
+ expose: view.expose,
235
+ view: name
235
236
  }).replace(/</g, '\\u003c')}</script>` : '';
236
237
  html = html.replace(/<head\b[^>]*>/i, (head)=>`${head}<base href="${escapeAttribute(base.href)}">${config}`);
237
238
  return {
@@ -41,7 +41,7 @@ function createMfView(config) {
41
41
  if (!mod?.default) throw new Error(`Missing default export for ${config.expose}`);
42
42
  return {
43
43
  default: createMcpView(mod.default, {
44
- name: config.name,
44
+ name: config.view,
45
45
  version: '1.0.0'
46
46
  }, mod.mcpCapabilities)
47
47
  };
@@ -19,8 +19,13 @@ function validateUiMetadata(value) {
19
19
  function validateViewsConfig(value) {
20
20
  if (!isRecord(value) || !isRecord(value.views)) throw new Error('mcp-views.json requires a views object');
21
21
  validateUiMetadata(value);
22
+ if (void 0 !== value.remote) {
23
+ if ('string' != typeof value.remote) throw new Error('remote must be a string');
24
+ assertRemoteManifest(value.remote);
25
+ }
22
26
  for (const key of Object.keys(value))if (![
23
27
  'views',
28
+ 'remote',
24
29
  'csp',
25
30
  'prefersBorder',
26
31
  'permissions',
@@ -29,10 +34,20 @@ function validateViewsConfig(value) {
29
34
  for (const [name, view] of Object.entries(value.views)){
30
35
  if (!name.trim()) throw new Error('View names must be non-empty');
31
36
  if (!('string' == typeof view && view.trim())) {
32
- if (!(isRecord(view) && 'string' == typeof view.expose && view.expose.startsWith('./')) || 1 !== Object.keys(view).length) throw new Error(`Invalid view: ${name}. Use a component path or { expose: './Name' }`);
37
+ if (isRecord(view) && 'string' == typeof view.expose && view.expose.startsWith('./') && Object.keys(view).every((key)=>'expose' === key || 'remote' === key)) {
38
+ if (!('remote' in view)) continue;
39
+ if ('string' == typeof view.remote) {
40
+ assertRemoteManifest(view.remote);
41
+ continue;
42
+ }
43
+ }
44
+ throw new Error(`Invalid view: ${name}. Use a component path, { expose: './Name' } or { remote: 'https://host/mf-manifest.json', expose: './Name' }`);
33
45
  }
34
46
  }
35
47
  }
48
+ function assertRemoteManifest(remote) {
49
+ if (!/^https?:\/\//.test(remote) || !assertHttpUrl(remote).pathname.endsWith('.json')) throw new Error(`Invalid MF remote: ${remote}. Use https://host/mf-manifest.json`);
50
+ }
36
51
  function assertHttpUrl(source) {
37
52
  const url = new URL(source);
38
53
  if (![
@@ -41,7 +41,7 @@ function createUiManifest(config, prefix, mf, csp) {
41
41
  if (void 0 !== csp) validateUiMetadata({
42
42
  csp
43
43
  });
44
- const { views, ...metadata } = config;
44
+ const { views, remote: defaultRemote, ...metadata } = config;
45
45
  const assetOrigin = assetPrefixOrigin(prefix);
46
46
  const mergedCsp = mergeCsp(metadata.csp, csp, assetOrigin ? {
47
47
  resourceDomains: [
@@ -64,23 +64,36 @@ function createUiManifest(config, prefix, mf, csp) {
64
64
  remotes: Object.create(null),
65
65
  views: Object.create(null)
66
66
  };
67
- for (const [name, source] of Object.entries(views))if ('string' == typeof source) manifest.views[name] = {
68
- html: `./ui/${viewEntryName(name)}.html`,
69
- ui
70
- };
71
- else {
72
- if (!mf) throw new Error(`View ${name} requires a Module Federation manifest. Enable the MF plugin and manifest output`);
73
- if (!mf.manifest.exposes.some((item)=>item.name.replace(/^\.\//, '') === source.expose.slice(2))) throw new Error(`View ${name} references missing MF expose: ${source.expose}`);
74
- const assetPath = 'string' == typeof prefix && prefix.startsWith('/') && !prefix.startsWith('//') ? prefix.replace(/^\/+|\/+$/g, '') : '';
75
- manifest.remotes[mf.manifest.name] = {
76
- entry: [
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 = [
77
86
  assetPath,
78
87
  mf.filename
79
- ].filter(Boolean).join('/'),
88
+ ].filter(Boolean).join('/');
89
+ remote = mf.manifest.name;
90
+ }
91
+ manifest.remotes[remote] = {
92
+ entry,
80
93
  shell: './ui/mcp-shell.html'
81
94
  };
82
95
  manifest.views[name] = {
83
- remote: mf.manifest.name,
96
+ remote,
84
97
  expose: source.expose,
85
98
  ui
86
99
  };
@@ -325,7 +338,7 @@ const mcpAppsPlugin = (options = {})=>({
325
338
  ].includes(ctx.command);
326
339
  const output = node_path.join(development ? ctx.internalDirectory : ctx.distDirectory, 'mcp-apps');
327
340
  let mf;
328
- if (Object.values(definition.views).some((view)=>'string' != typeof view)) mf = findMfManifest(compilation.getAssets(), options.mf?.manifestFile ?? 'mf-manifest.json');
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');
329
342
  const config = api.getNormalizedConfig();
330
343
  const manifest = createUiManifest(definition, development ? config.dev.assetPrefix : config.output.assetPrefix, mf, options.csp);
331
344
  for (const name of localEntries){
@@ -140,7 +140,8 @@ async function registerUiResources(server, options = {}) {
140
140
  const config = remote && 'expose' in view ? `<script type="application/json" id="modern-mcp-config">${JSON.stringify({
141
141
  name: view.remote,
142
142
  entry,
143
- expose: view.expose
143
+ expose: view.expose,
144
+ view: name
144
145
  }).replace(/</g, '\\u003c')}</script>` : '';
145
146
  html = html.replace(/<head\b[^>]*>/i, (head)=>`${head}<base href="${escapeAttribute(base.href)}">${config}`);
146
147
  return {
@@ -1,8 +1,12 @@
1
1
  import type { McpUiResourceMeta } from '@modelcontextprotocol/ext-apps';
2
2
  export type UiMetadata = McpUiResourceMeta;
3
3
  export interface McpViewsConfig extends UiMetadata {
4
+ /** Default deployed MF remote for expose views. Individual views may override it. */
5
+ remote?: string;
6
+ /** A component path, an expose of this application, or an expose of a deployed remote. */
4
7
  views: Record<string, string | {
5
8
  expose: string;
9
+ remote?: string;
6
10
  }>;
7
11
  }
8
12
  export interface UiManifest {
@@ -24,5 +28,11 @@ export interface UiManifest {
24
28
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
25
29
  export declare function validateUiMetadata(value: unknown): asserts value is UiMetadata;
26
30
  export declare function validateViewsConfig(value: unknown): asserts value is McpViewsConfig;
31
+ /**
32
+ * A deployed MF remote must be a manifest URL. The MF runtime finds the
33
+ * container through the manifest only for `.json` entries. Any other entry
34
+ * would need the remote's real MF name.
35
+ */
36
+ export declare function assertRemoteManifest(remote: string): void;
27
37
  export declare function assertHttpUrl(source: string): URL;
28
38
  export declare function validateUiManifest(value: unknown): asserts value is UiManifest;
@@ -3,6 +3,8 @@ export interface MfViewConfig {
3
3
  name: string;
4
4
  entry: string;
5
5
  expose: string;
6
+ /** The view name, reported to the host as the app name like local cards. */
7
+ view: string;
6
8
  }
7
9
  /** Executed in the application build, so the shell and exposes share its React. */
8
10
  export declare function createMfView(config: MfViewConfig): () => JsxRuntime.JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modern-js/mcp-apps",
3
- "version": "3.11.1",
3
+ "version": "3.11.2",
4
4
  "description": "Build, serve and render MCP Apps in Modern.js applications",
5
5
  "license": "MIT",
6
6
  "homepage": "https://modernjs.dev",
@@ -51,7 +51,7 @@
51
51
  "@module-federation/enhanced": "^2.0.0",
52
52
  "@modelcontextprotocol/server": "^2.3.1",
53
53
  "@modelcontextprotocol/ext-apps": "^2.0.4",
54
- "@modern-js/server-runtime": "^3.11.1"
54
+ "@modern-js/server-runtime": "^3.11.2"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@module-federation/enhanced": {
@@ -74,9 +74,9 @@
74
74
  "zod": "^4.2.0",
75
75
  "@modelcontextprotocol/server": "2.3.1",
76
76
  "@modelcontextprotocol/ext-apps": "2.0.4",
77
- "@scripts/rstest-config": "2.66.5",
78
- "@modern-js/app-tools": "3.11.1",
79
- "@modern-js/server-runtime": "3.11.1"
77
+ "@scripts/rstest-config": "2.66.6",
78
+ "@modern-js/server-runtime": "3.11.2",
79
+ "@modern-js/app-tools": "3.11.2"
80
80
  },
81
81
  "publishConfig": {
82
82
  "access": "public",