ui:// resource, and Vanilla TypeScript App work together.
When to Use This Pattern
Use a plugin-managed MCP server when you want to:- expose tools through the MCP protocol instead of an Xpert-native ToolsetStrategy
- keep the tool implementation portable across MCP hosts
- return an interactive MCP App from a tool result
- ship app-only tools that are callable by the iframe but not visible to the model
- install, enable, disable, and version the MCP server as part of a plugin
Package Layout
A minimal plugin-managed MCP App package usually has this shape. Product-ready tool plugins normally live undertools/; tutorial or reference implementations live under examples/.
src/index.ts can stay lightweight when the package only contributes a plugin-managed MCP server. It should still export normal plugin metadata so the host can list, install, and initialize the plugin.
package.json
The MCP server entry must be included in the published package and built intodist.
@xpert-ai/plugin-sdk in peerDependencies so the host provides the SDK copy. Keep @modelcontextprotocol/sdk in dependencies when the stdio server imports it directly. MCP Apps plugins should also depend on @modelcontextprotocol/ext-apps and use the official registerAppTool, registerAppResource, and RESOURCE_MIME_TYPE helpers. ext-apps is ESM-only, so use "type": "module" and Node.js 20 or later for the plugin server.
Plugin Manifest
Declare the MCP server in.xpertai-plugin/plugin.json.
${PLUGIN_ROOT} instead of an absolute installed runtime path. Xpert stores the placeholder in the generated Toolset schema and resolves it at runtime to the currently loaded plugin bundle. This prevents stale @runtime__... paths after a plugin is reinstalled or reloaded.
Use ${PLUGIN_DATA} for plugin-owned writable data if the server needs local state.
Runtime and Enterprise Security
A plugin-managed MCP stdio server is not started when the plugin is installed. The runtime starts when Xpert initializes the MCP Toolset for tool discovery, which normally happens while an agent graph is being compiled or invoked. v1 still uses thison-toolset-init timing; on-first-tool-call would require cached tool metadata and is reserved for a later optimization.
In enterprise deployments, stdio MCP servers run through the Xpert controlled stdio runtime:
- Xpert resolves
${PLUGIN_ROOT}and${PLUGIN_DATA}for the current plugin runtime copy. - Xpert validates tenant, organization, workspace, Toolset, server name, and whether the server is plugin-managed.
- Xpert rewrites the MCP server command to a platform runner instead of passing the raw command to the MCP adapter.
- The runner launches the real child process with
shell: false, sanitized env, a controlled cwd, stderr capture, startup/lifetime/idle timeouts, and process-group cleanup. MCPToolset.close()and graph completion/abort cleanup close the registered runtime.
${PLUGIN_ROOT}. Xpert rejects stale absolute @runtime__... paths, path traversal, and symlink escapes. The plugin code directory is treated as read-only source; writable state should go under ${PLUGIN_DATA}. Custom, non-plugin-managed stdio commands in production must match the admin command allowlist.
initScripts are disabled in production by default. If an administrator explicitly enables them, they still run under the same runtime and policy controls.
Plugins may request runtime limits in policy.runtime, but the platform policy remains authoritative and clamps the final values:
Runtime Operations and Audit
Super administrators can inspect runtime records and stop still-running instances through the MCP Runtime Operations page or/api/xpert-toolset/operations/mcp-runtimes. The API is scoped to the current tenant and current organization by default.
Runtime management has two layers:
- The in-memory runtime manager represents only live runtimes that the current API process can still control.
- The
mcp_runtime_instanceaudit table stores runtime lifecycle records and relationships. Queries default to the most recent 180 days.
livemeans the runtime is still present in the current API process runtime manager and can be stopped by the platform.running/startingare record statuses; historical records that are not live cannot be stopped.- Before listing runtimes, the runtime manager checks whether the runner PID still exists. If the process already exited, the record is reconciled to a closed/failed state and removed from the live list.
origindistinguishes the runtime source:Agent toolsetmeans an Agent initialized the Toolset, whileMCP App Hostmeans an MCP App revive or iframe host recreated a client for resource/RPC access.
- Row-level Stop stops only that live runtime.
- Header-level Stop filtered stops all live
starting/runningruntimes matching the current filters. Historical records are read-only and are not stopped.
admin-stop, admin-kill, idle-timeout, max-lifetime-timeout, transport-close, or runner-process-exited.
MCP Server Entry
The stdio entry should create the MCP server, register resources and tools, and connect toStdioServerTransport.
stdout from a stdio MCP server because stdout is reserved for MCP protocol messages. Write diagnostics to stderr.
Register an MCP App Resource
An MCP App resource must use aui:// URI and return text/html;profile=mcp-app.
title, description, and icon belongs on resource _meta.ui. title and description can be plain strings or Xpert-style I18nObject values; icon uses the shared IconDefinition shape (svg, image, font, emoji, or lottie). ChatKit localizes title and description with the current host language and renders the icon in the MCP App message header.
Resource security and rendering metadata belongs on resource _meta.ui. Content item metadata from resources/read takes precedence; _meta.ui in the registerAppResource config appears in resources/list and acts as a host fallback. Do not put CSP or permissions on tool _meta.ui except as a temporary legacy compatibility fallback. Tool _meta.ui may mirror display metadata as a short-lived fallback, but the resource remains authoritative.
For small demos, returning HTML from a TypeScript function is acceptable. For production apps, prefer keeping the app source in normal frontend files and bundling it to a static HTML or JS asset during build. The MCP resource handler can then read the built asset from dist. This keeps the app maintainable and avoids hand-editing a large template string.
The recommended centralized authoring pattern is to keep the MCP App frontend source inside the plugin package under src/app:
index.html owns the shell, main.ts owns the standard MCP Apps bridge and interactions, and styles.css owns the view styles. Use Vite or esbuild during the plugin build to produce a single dist/app/index.html that the MCP resource can return. A server-side helper such as src/lib/app-html.ts should only read the built artifact; it should not hand-maintain a large HTML string.
With esbuild, the minimal build script can read src/app/index.html, bundle src/app/main.ts, inline src/app/styles.css, and write dist/app/index.html. Wire that into the plugin build, for example:
nx build <plugin> also runs the App build before or after compiling the server code, and verify the published package includes dist, .xpertai-plugin, and any scripts or source directories required by the build/runtime path.
Register Tools
The model-visible tool opens the MCP App by returning_meta.ui.resourceUri. It can also return structuredContent for the app’s initial state and plain text as a fallback for clients that do not render the UI.
registerAppTool normalizes _meta.ui.resourceUri and keeps compatibility resource URI metadata. _meta['openai/outputTemplate'] can be included as a ChatGPT compatibility alias, but Xpert + ChatKit does not implement the ChatGPT-specific window.openai API.
App-only tools are callable through the MCP Apps bridge but are filtered out of the LLM tool list.
- tools without
modelvisibility are not exposed to the LLM - iframe calls are rejected unless the target tool has
appvisibility and is enabled by Toolset policy
App HTML
The app runs inside a sandboxed iframe and communicates with ChatKit through the MCP Apps JSON-RPC bridge. Keep the plugin documentation focused on authoring and packaging; the full host contract is documented in ChatKit MCP Apps. At minimum, the HTML app should:- initialize with
ui/initialize - handle
ui/notifications/tool-inputfor the original tool arguments - handle
ui/notifications/tool-resultfor the original tool result - call app-visible tools with
tools/call - notify size changes with
ui/notifications/size-changed - avoid direct API calls to the Xpert backend
CallToolResult shape from ui/notifications/tool-result.params.content and params.structuredContent; temporarily fall back to params.result only for older hosts. Apps should also tolerate missing initial tool-result notifications: legacy history messages or oversized results may provide only tool-input, in which case the app should show a summary, offer a rerun, or call app-visible tools to reload/page data on demand. Keep the app source as normal frontend code when it grows beyond a small demo, and treat the MCP resource as the delivery mechanism for the built artifact.
Internationalization
Plugin apps should localize their own iframe UI from theui/initialize result. ChatKit provides hostContext.locale, hostContext.language, and hostContext.direction, and also sets the HTML document lang / dir attributes before the app runs. Keep MCP tool results structured and language-neutral where possible, then format labels, summaries, numbers, dates, chart titles, and validation messages in src/app/main.ts or your frontend framework.
Theme and Styling
ChatKit injects public CSS variables into the MCP App iframe. See Theme Variables in ChatKit MCP Apps for the full list and examples. Plugin apps should use the generic--mcp-app-* variables for backgrounds, text, borders, buttons, radius, and fonts. Do not depend on ChatKit internal classes or private tokens; other MCP Apps hosts can reuse the same contract. Business chart data series can define an app-owned semantic palette so muted UI colors or grayscale host tokens are not used directly for bars, lines, or points.
getComputedStyle(document.documentElement) after app initialization, or read the same values from hostContext.themeCssVariables in the ui/initialize result.
If the app loads CDN assets, declare the domains in resource _meta.ui.csp.resourceDomains. If the app needs network calls through the browser, declare the target domains in resource _meta.ui.csp.connectDomains; prefer resources/read or tools/call for host-mediated access.
Build and Install
Build the plugin and make sure the generated package containsdist/mcp-server.js and .xpertai-plugin/plugin.json.
dist files and manifest.
Test Checklist
Cover these cases before shipping:- stdio server starts without writing normal logs to stdout
- App build output
dist/app/index.htmlis generated and contains the MCP Apps bridge script tools/listreturns the model-visible tool and excludes app-only tools- model-visible tool result includes
_meta.ui.resourceUri - MCP App resource returns
text/html;profile=mcp-app - MCP App resource metadata includes the required
_meta.ui.csp,_meta.ui.permissions, andprefersBorder structuredContenthas the shape expected by the iframe- app-only tool has
_meta.ui.visibility = ['app'] - Toolset policy enables every tool the app needs
- iframe can initialize, receive the original tool input and result, call app-only tools, and resize
- external scripts and connections are covered by resource CSP metadata
- plugin reinstall does not persist stale absolute
@runtime__...paths
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot find module ... @runtime__... | The persisted MCP schema contains an old installed runtime path | Use ${PLUGIN_ROOT} in plugin.json, rebuild the plugin, and reinstall or reinitialize older plugin resources. |
| MCP server exits immediately | Missing built dist/mcp-server.js, bad ESM/CJS config, or an exception during server setup | Run node dist/mcp-server.js locally and check stderr. Confirm type, exports, and files in package.json. |
| Tool appears in model tools even though it is app-only | Missing _meta.ui.visibility = ['app'] or host metadata did not preserve _meta | Set visibility on the tool and verify the generated Toolset metadata. |
App can load but tools/call fails | Target tool is not app-visible or disabled by Toolset policy | Add app visibility and enable the tool in mcpServers.*.policy.enabledTools. |
| CDN script is blocked | Resource CSP metadata does not include the CDN domain | Add the domain to resource _meta.ui.csp.resourceDomains. |