MCP Apps
Modern.js builds React cards for MCP hosts. UI builds and servers exchange only ui-manifest.json. Servers use the official MCP SDK directly.
Create a project
Choose a release containing MCP Apps support. Run pnpm dev. The explicit BFF route is /api/mcp.
@modern-js/mcp-apps provides the build plugin, server plugin, server adapters and card runtime through ./cli, ./server-plugin, ./server and ./react. The application must install the peers react, react-dom, @modelcontextprotocol/server and @modelcontextprotocol/ext-apps, so cards use the application's React and tools, resources and HTTP handling use the application's SDK versions. @modern-js/server-runtime and @module-federation/enhanced are optional peers that provide the default request context for BFF handlers and the default MF shell runtime. The project templates already declare these dependencies.
Build cards
Declare local default-export React components, this project's MF exposes, or exposes of deployed remotes in mcp-views.json:
Import mcpAppsPlugin from @modern-js/mcp-apps/cli and add mcpAppsPlugin({ views: './mcp-views.json' }) to your Modern.js plugins. Paths resolve relative to the JSON file. MF cards require the Modern.js MF plugin with manifest output enabled. The UI plugin validates exposes against the actual emitted MF manifest and builds the shell with the application's React dependencies.
The plugin finds the MF manifest in the web build output by filename, mf-manifest.json by default, and requires exactly one match. The shell imports createInstance from @module-federation/enhanced/runtime by default. Other MF implementations can replace both through the mf option:
Reference deployed remotes
When another project publishes the card as an MF remote, set remote to the manifest URL of the remote:
The top-level remote is shared by all expose views. A view can set its own remote to override it. Component paths still reference local components. Without a top-level or view-level remote, exposes refer to this application. With a top-level remote, expose views in the same config cannot refer to this application's exposes. To mix both, omit the top-level remote and set remote only on the views that reference a deployed remote.
- The URL must be an absolute HTTP(S) URL whose path ends with
.json. Entries that are not manifests, such asremoteEntry.js, are not supported - The remote's name is not needed. The runtime reads it from the manifest
- The shell reads the remote manifest and loads the expose when the card opens. Republishing the remote needs no rebuild or restart of the MCP server. Exposes are not checked at build time. A missing expose shows a load error in the card
- This project does not need the MF plugin, but it must install the runtime that
mf.runtimepoints to. Setmf.runtimeas above when the remote uses another MF implementation - The manifest origin is added to CSP automatically. Declare other origins serving the remote's scripts and styles, such as a CDN, in
csp.resourceDomains. The manifest and scripts must allow cross-origin requests - The remote must share React as a singleton so it uses the shell's React
- Adding a card or changing
remoteorexposestill requires rebuilding the MCP server
Local cards build as separate entries without the framework page runtime. With source.enableAsyncEntry, each entry adds a dynamic import boundary so MF shared dependencies can initialize. The MF plugin enables this option automatically. Otherwise cards mount directly without an extra request.
Props come from successful structuredContent. The reserved mcpApp prop is injected by the wrapper. Input arguments are not merged into props. New input, cancellation and teardown clear previous output. The wrapper manages connection, host styling and loading/error states. Results from tools called inside a card must be handled by the component itself.
Write the server
Enable bffPlugin() and create api/lambda/mcp.ts. The UI plugin does not register any HTTP route.
The adapter reconstructs BFF's consumed JSON body and returns the SDK Response. It reads the raw request through useHonoContext from @modern-js/server-runtime. Frameworks built on Modern.js can pass their own implementation with createBffHandlers(createServer, { useContext }). The official SDK owns protocol serving and tool validation. Serving is stateless, so unsupported session operations such as GET and DELETE are rejected by the SDK. Return a fresh server per request. Put authentication and authorization in surrounding middleware or the factory, and persist business data in an external store.
For independent Hono servers, create handle = createFetchHandler(createServer) and mount app.all('/mcp', c => handle(c.req.raw)). Foreign browser origins are rejected unless listed in allowedOrigins in the adapter's second argument.
Manifest and deployment
Builds emit dist/mcp-apps/ui-manifest.json and dist/mcp-apps/ui/*.html. Static JavaScript, CSS and MF files remain in the application's standard output. MF-only projects also emit a UI manifest. Local HTML filenames use a stable hash of the view name. MF cards share mcp-shell.html.
The version 1 manifest contains assetBase, remotes and views. A local view has { html, ui }. An MF view has { remote, expose, ui }, with its remote declaring { entry, shell }.
- HTML and shell paths resolve relative to the manifest location
- MF entry paths resolve relative to
assetBase - Absolute HTTP(S) sources are supported for independent deployments
uipreserves CSP, permissions, domain and border preferences- Resource URIs are generated as
ui://app/<encoded-view-name>by the server, and multiple tools can share a card - MF configuration is injected into resource HTML instead of tool results
createBffHandlers() associates the server plugin’s manifest path and runtime mode with the current request. Call registerUiResources(server, { request }) to resolve them automatically. Development uses the framework internal directory and production uses the runtime output directory. Explicit options override injected configuration. Independent services must provide a manifest file path or HTTP(S) URL. The mode option defaults to production. Relative HTML resolves against the manifest location.
Manifests are cached by source URL and environment. Production caches successful loads for the process lifetime, so updating the same location requires a process restart. Development detects local file changes and caches remote manifests for 60 seconds. Concurrent loads are coalesced and failures can be retried. HTML is read per resource request, and request-specific URLs and metadata remain isolated.
An absolute output.assetPrefix becomes assetBase. Otherwise request-origin resolution is used. Override assetBase in registerUiResources for a separate static origin or deployment path prefix. Request mode honors sanitized x-forwarded-proto headers. Reverse proxies must preserve the public host and sanitize forwarded headers.
Required asset, connection and base URI origins are added to CSP. MF entry origins are also added to connection permissions. The origin of an absolute or protocol-relative output.assetPrefix is added at build time, with //cdn.example/ treated as https://cdn.example. Declare any other API, chunk/CDN or iframe origins in mcp-views.json, or pass domains computed in the config through mcpAppsPlugin({ csp }). Both are merged into every view. Deploy the manifest and HTML together with the application static assets and MF output.
Platforms may rewrite file and MF addresses to absolute URLs and combine cards into another manifest of the same version. Unknown versions are rejected.
Card capabilities
Cards advertise inline and fullscreen by default. Both local component modules and MF exposes can export mcpCapabilities to declare additional capabilities or override the display modes.
You can also pass capabilities as the third argument to createMcpView. Explicit display mode arrays replace the defaults. MF modules are loaded before initialization so the handshake includes their capabilities. Declaring fullscreen support does not add a fullscreen button or switch modes automatically.