跳转到主要内容
插件可以把 MCP server 作为可安装资源交付。这是为 Xpert 增加标准 MCP tools 的推荐方式,也是交付可在 ChatKit 中内联渲染的 MCP Apps 的推荐方式。 本文只讲插件侧如何打包和实现。ChatKit 宿主运行时、iframe bridge、消息渲染和安全模型请参考 ChatKit MCP Apps 如果你想从一个完整业务案例入手,可以继续阅读 开发一个销售经营分析 MCP App 插件。该教程以销售经营分析为例,展示如何把 model-visible tool、app-only tool、ui:// resource 和 Vanilla TypeScript App 组合成一个可交互插件。

适用场景

当你需要以下能力时,使用 plugin-managed MCP server:
  • 通过 MCP 协议暴露工具,而不是实现 Xpert 原生 ToolsetStrategy
  • 让工具实现尽量保持 MCP host 可移植性
  • 在工具结果中返回可交互的 MCP App
  • 提供 iframe 可调用、但模型不可见的 app-only 工具
  • 把 MCP server 的安装、启用、停用和版本管理纳入插件生命周期
如果要开发长期存在的 Workbench 页面或集成配置页面,应使用 Remote Component 插件 或其他 View Extension 能力。不要让 Agent middleware 承担 MCP App resource host 的职责;middleware 可以触发工作流,但 MCP Apps 应通过 MCP resource 和 MCP Apps Host 交付。

包结构

一个最小的 plugin-managed MCP App 包通常是。面向正式发布的工具插件通常放在 tools/;教程和参考实现放在 examples/
tools/my-mcp-app/
├── .xpertai-plugin/
│   └── plugin.json
├── package.json
├── src/
│   ├── index.ts
│   ├── mcp-server.ts
│   └── lib/
│       ├── mcp-tools.ts
│       ├── app-html.ts
│       └── data.ts
└── tsconfig.lib.json
如果插件只贡献 plugin-managed MCP server,src/index.ts 可以很轻量,但仍应导出标准插件元数据,便于宿主展示、安装和初始化资源。
import { z } from 'zod';
import type { XpertPlugin } from '@xpert-ai/plugin-sdk';
import { MyMcpAppPluginModule } from './lib/plugin.js';

const plugin: XpertPlugin = {
  meta: {
    name: '@acme/plugin-sales-mcp-app',
    version: '0.1.0',
    category: 'tools',
    targetApps: ['xpert'],
    displayName: 'Sales MCP App',
    description: 'Interactive sales analysis tools delivered through MCP Apps.',
  },
  config: {
    schema: z.object({}),
  },
  register() {
    return { module: MyMcpAppPluginModule, global: true };
  },
};

export default plugin;

package.json

MCP server 入口必须被构建到 dist,并包含在发布包中。
{
  "name": "@acme/plugin-sales-mcp-app",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "bin": {
    "acme-sales-mcp-app": "./dist/mcp-server.js"
  },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./mcp-server": {
      "types": "./dist/mcp-server.d.ts",
      "import": "./dist/mcp-server.js"
    }
  },
  "files": [
    "dist",
    ".xpertai-plugin",
    "README.md"
  ],
  "dependencies": {
    "@modelcontextprotocol/ext-apps": "^1.7.4",
    "@modelcontextprotocol/sdk": "^1.29.0"
  },
  "peerDependencies": {
    "@xpert-ai/plugin-sdk": "^3.9.1",
    "zod": "^3.25.0"
  }
}
@xpert-ai/plugin-sdk 放在 peerDependencies,由宿主提供 SDK 实例。stdio server 直接 import @modelcontextprotocol/sdk 时,应把它放在 dependencies。MCP Apps 插件推荐同时依赖 @modelcontextprotocol/ext-apps,使用官方 registerAppToolregisterAppResourceRESOURCE_MIME_TYPE helper。ext-apps 是 ESM 包,建议插件 server 使用 "type": "module" 并运行在 Node.js 20 或更高版本。

插件 Manifest

.xpertai-plugin/plugin.json 中声明 MCP server:
{
  "name": "@acme/plugin-sales-mcp-app",
  "version": "0.1.0",
  "targetApps": ["xpert"],
  "targetAppMeta": {
    "xpert": {
      "types": ["mcp-server", "tool"],
      "capabilities": ["mcp-apps", "sales-analysis"]
    }
  },
  "mcpServers": {
    "sales-drilldown": {
      "type": "stdio",
      "command": "node",
      "args": ["${PLUGIN_ROOT}/dist/mcp-server.js"],
      "policy": {
        "enabled": true,
        "defaultToolsApprovalMode": "approve",
        "enabledTools": [
          "sales_overview",
          "sales_drilldown"
        ],
        "tools": {
          "sales_overview": {
            "approvalMode": "approve"
          },
          "sales_drilldown": {
            "approvalMode": "approve"
          }
        }
      }
    }
  }
}
使用 ${PLUGIN_ROOT},不要把安装后的绝对 runtime 路径写入 manifest。Xpert 会把占位符保存在生成的 Toolset schema 中,并在运行时解析到当前加载的插件目录。这样插件重装或重新加载后,不会因为旧的 @runtime__... 路径失效。 如果 server 需要插件自己的可写数据目录,可以使用 ${PLUGIN_DATA}

Runtime 与企业级安全

plugin-managed MCP stdio server 不会在插件安装时启动。它会在 Xpert 初始化 MCP Toolset 以发现工具时启动,通常发生在 agent graph 编译或调用阶段。v1 仍保持 on-toolset-init 时机;on-first-tool-call 需要缓存工具元数据,后续再优化。 在企业部署中,stdio MCP server 会经过 Xpert 受控 stdio runtime:
  1. Xpert 为当前插件 runtime 副本解析 ${PLUGIN_ROOT}${PLUGIN_DATA}
  2. Xpert 校验 tenant、organization、workspace、Toolset、serverName 以及是否为 plugin-managed。
  3. Xpert 把 MCP server command 改写为平台 runner,而不是把原始命令直接交给 MCP adapter。
  4. runner 使用 shell: false 启动真实子进程,并限制 env、cwd、stderr 采集、启动超时、空闲超时、最大生命周期和进程组清理。
  5. MCPToolset.close() 以及 graph 正常完成、异常、abort 清理都会关闭登记的 runtime。
生产环境默认 fail-closed。只有平台策略配置完成后才启用 stdio runtime:
XPERT_MCP_STDIO_RUNTIME_ENABLED=true
XPERT_MCP_STDIO_ALLOWED_COMMANDS=node
XPERT_MCP_STDIO_MAX_CONCURRENT_PER_TENANT=10
XPERT_MCP_STDIO_STARTUP_TIMEOUT_MS=15000
XPERT_MCP_STDIO_IDLE_TIMEOUT_MS=1800000
XPERT_MCP_STDIO_MAX_LIFETIME_MS=7200000
plugin-managed stdio server 应通过 Node.js 启动 ${PLUGIN_ROOT} 内的入口文件。Xpert 会拒绝旧的绝对 @runtime__... 路径、路径穿越和 symlink 逃逸。插件代码目录只作为只读来源,可写状态应放到 ${PLUGIN_DATA}。生产环境中的 custom stdio 命令必须命中管理员配置的 command allowlist。 生产环境默认禁用 initScripts。如果管理员显式启用,也必须走同一套 runtime 和策略限制。 插件可以在 policy.runtime 中请求 runtime 限制,但最终以平台策略为准并被 clamp:
{
  "policy": {
    "runtime": {
      "provider": "local-process",
      "startupTimeoutMs": 15000,
      "idleTimeoutMs": 1800000,
      "maxLifetimeMs": 7200000,
      "allowedCommands": ["node"]
    }
  }
}

Runtime 运维与审计

Super Admin 可以通过 MCP Runtime 运维页或 /api/xpert-toolset/operations/mcp-runtimes 查看 runtime 记录并停止仍在运行的实例。该接口默认只返回当前租户和当前组织范围内的数据。 runtime 管理分为两层:
  • 内存中的 runtime manager 只表示当前进程内仍可控制的 live runtime。
  • mcp_runtime_instance 审计表保存 runtime 生命周期与关联关系,默认按最近 180 天查询。
审计记录包含 tenant、organization、workspace、Toolset、plugin、server、execution、conversation、app instance、resource installation、status、PID、启动时间、关闭时间、关闭原因、空闲过期时间、最长生命周期时间和截断后的 stderr 摘要。记录只保存脱敏后的 command label/hash 和 policy snapshot,不保存 env、token、runner spec 或原始 HTML。 运维页支持按 workspace、Toolset、plugin、execution、app instance、status 和时间范围查询。筛选项来自审计记录和当前 live runtime 的合并结果;workspace 选项和表格范围列会优先显示工作区名称,无法解析名称时回退到短 ID。Toolset 选项优先显示 Toolset 名称。 状态语义:
  • live 表示该 runtime 仍在当前 API 进程的 runtime manager 中,且可以被平台停止。
  • running / starting 只表示记录状态;历史记录如果不是 live,不能再被 stop。
  • runtime manager 在列表查询时会校验 runner PID 是否仍存在;如果进程已经退出,会把记录收敛为关闭/失败状态并移出 live 列表。
  • origin 用于区分 runtime 来源:Agent toolset 表示 Agent 初始化 Toolset 时启动,MCP App Host 表示 MCP App revive 或 iframe host 为资源/RPC 重新创建 client 时启动。
运维页的 Actions 列提供两类操作:
  • 每行的 停止:只停止该行对应的 live runtime。
  • 表头的 停止当前筛选:停止当前筛选条件下所有 live 且处于 starting/running 的 runtime;历史记录只读,不会被停止。
所有停止操作仍通过受控 runtime manager 执行,关闭原因会写入审计记录,例如 admin-stopadmin-killidle-timeoutmax-lifetime-timeouttransport-closerunner-process-exited

MCP Server 入口

stdio 入口负责创建 MCP server、注册 resources/tools,并连接 StdioServerTransport
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { pathToFileURL } from 'node:url';
import { registerSalesMcpApp } from './lib/mcp-tools.js';

export function createSalesMcpServer() {
  const server = new McpServer({
    name: 'acme-sales-mcp-app',
    version: '0.1.0',
  });

  registerSalesMcpApp(server);
  return server;
}

export async function main() {
  const server = createSalesMcpServer();
  await server.connect(new StdioServerTransport());
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((error) => {
    const message = error instanceof Error ? error.stack || error.message : String(error);
    process.stderr.write(`${message}\n`);
    process.exit(1);
  });
}
stdio MCP server 不要把普通日志写入 stdout,因为 stdout 是 MCP 协议通道。诊断日志应写入 stderr

注册 MCP App Resource

MCP App resource 必须使用 ui:// URI,并返回 text/html;profile=mcp-app
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import {
  RESOURCE_MIME_TYPE,
  registerAppResource,
} from '@modelcontextprotocol/ext-apps/server';
import { getDashboardHtml } from './app-html.js';

const SALES_APP_URI = 'ui://sales-dashboard';
const SALES_APP_CSP = {
  resourceDomains: ['https://cdn.jsdelivr.net'],
  connectDomains: [],
  frameDomains: [],
  baseUriDomains: [],
};
const SALES_APP_DISPLAY = {
  title: {
    en_US: 'Sales Performance',
    zh_Hans: '销售经营分析',
  },
  description: {
    en_US: 'Interactive sales dashboard with drilldown analysis.',
    zh_Hans: '支持下钻分析的交互式销售看板。',
  },
  icon: {
    type: 'svg',
    value: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 19h16v2H4z"/></svg>',
    alt: 'Sales dashboard',
  },
};

function buildDashboardResource(): ReadResourceResult {
  return {
    contents: [
      {
        uri: SALES_APP_URI,
        mimeType: RESOURCE_MIME_TYPE,
        text: getDashboardHtml(),
        _meta: {
          ui: {
            ...SALES_APP_DISPLAY,
            csp: SALES_APP_CSP,
            prefersBorder: true,
          },
        },
      },
    ],
  };
}

export function registerSalesMcpApp(server: McpServer) {
  registerAppResource(
    server,
    'sales-dashboard',
    SALES_APP_URI,
    {
      title: 'Sales Dashboard',
      description: 'Interactive sales dashboard rendered in ChatKit.',
      _meta: {
        ui: {
          ...SALES_APP_DISPLAY,
          csp: SALES_APP_CSP,
          prefersBorder: true,
        },
      },
    },
    () => buildDashboardResource(),
  );
}
resource 展示 metadata,例如 titledescriptionicon,应放在 resource 的 _meta.ui 上。titledescription 可以是普通字符串,也可以是 Xpert 风格的 I18nObjecticon 使用共享的 IconDefinition 结构(svgimagefontemojilottie)。ChatKit 会根据当前 host 语言解析 titledescription,并在 MCP App 消息头部渲染 icon。 资源安全与呈现 metadata 应放在 resource 的 _meta.ui 上。resources/read 返回的 content item metadata 优先;registerAppResource 配置中的 _meta.ui 会出现在 resources/list 中,可作为 host fallback。不要把 CSP 或权限放在 tool _meta.ui 中,旧 host 兼容除外。tool _meta.ui 可以少量镜像展示 metadata 作为短生命周期兜底,但 resource 仍然是权威来源。 小型 demo 可以用 TypeScript 函数返回 HTML 字符串。生产应用更推荐把 App 按普通前端工程维护,并在构建阶段产出静态 HTML 或 JS asset;MCP resource handler 再从 dist 读取构建产物。这样比手写和维护大段模板字符串更可靠。 推荐的集中开发方式是把 MCP App 前端源码放在插件包内的 src/app
src/app/
├── index.html
├── main.ts
└── styles.css
scripts/
└── build-app.mjs
dist/
└── app/
    └── index.html
其中 index.html 只维护页面外壳,main.ts 维护标准 MCP Apps bridge 和交互逻辑,styles.css 维护样式。构建时使用 Vite 或 esbuild 把它们打成一个可由 MCP resource 返回的 dist/app/index.htmlsrc/lib/app-html.ts 这类 server 侧 helper 应只负责读取构建产物,不再手写大段 HTML 字符串。 esbuild 方式的最小构建脚本可以读取 src/app/index.html,打包 src/app/main.ts,内联 src/app/styles.css,并输出 dist/app/index.html。将该脚本接入插件构建,例如:
{
  "scripts": {
    "build:app": "node scripts/build-app.mjs"
  }
}
如果使用 Nx,确保 nx build <plugin> 会在编译 server 代码前后执行 App 构建,并确认发布包的 files 包含 dist.xpertai-plugin 和必要的构建脚本或源码目录。

注册 Tools

模型可见工具通过返回 _meta.ui.resourceUri 打开 MCP App。工具结果也可以包含 structuredContent 作为 App 初始状态,并返回文本作为不支持 UI 的客户端降级内容。
import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';

const overviewMeta = {
  ui: {
    resourceUri: SALES_APP_URI,
    visibility: ['model', 'app'],
  },
  'openai/outputTemplate': SALES_APP_URI,
};

registerAppTool(
  server,
  'sales_overview',
  {
    title: 'Sales Overview',
    description: 'Show an interactive sales dashboard with drilldown analysis.',
    inputSchema: {
      metric: z.enum(['revenue', 'margin', 'orders']).default('revenue'),
      groupBy: z.enum(['region', 'product', 'month']).default('region'),
      year: z.number().int().default(2026),
    },
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    _meta: overviewMeta,
  },
  (args) => ({
    content: [{ type: 'text', text: 'Revenue by region for 2026.' }],
    structuredContent: {
      chart: {
        labels: ['West', 'East', 'South', 'North'],
        values: [7600000, 6500000, 5600000, 4800000],
      },
    },
    _meta: overviewMeta,
  }),
);
registerAppTool 会规范化 _meta.ui.resourceUri,并保留兼容用的 resource URI metadata。_meta['openai/outputTemplate'] 可作为 ChatGPT 兼容 alias,但 Xpert + ChatKit 不实现 ChatGPT 专属 window.openai API。 app-only 工具可以通过 MCP Apps bridge 调用,但不会暴露给 LLM。
registerAppTool(
  server,
  'sales_drilldown',
  {
    title: 'Sales Drilldown',
    description: 'App-only tool used by the dashboard after a chart click.',
    inputSchema: {
      metric: z.enum(['revenue', 'margin', 'orders']).default('revenue'),
      groupBy: z.enum(['region', 'product', 'month']).default('product'),
      year: z.number().int().default(2026),
      filters: z.object({
        region: z.string().optional(),
        product: z.string().optional(),
        month: z.string().optional(),
      }).default({}),
    },
    _meta: {
      ui: {
        visibility: ['app'],
      },
    },
  },
  (args) => ({
    content: [{ type: 'text', text: 'Drilldown data loaded.' }],
    structuredContent: {
      chart: {
        labels: ['Hardware', 'Software', 'Services'],
        values: [4100000, 2600000, 900000],
      },
      filters: args.filters,
    },
  }),
);
Xpert 会同时校验两侧可见性:
  • 没有 model visibility 的工具不会暴露给 LLM
  • iframe 调用目标必须具备 app visibility,并且在 Toolset policy 中已启用

App HTML

App 运行在 sandbox iframe 中,通过 MCP Apps JSON-RPC bridge 与 ChatKit 通信。插件文档只保留编写和打包要点;完整宿主协议请看 ChatKit MCP Apps HTML App 至少应该:
  • 通过 ui/initialize 初始化
  • 处理 ui/notifications/tool-input,读取原始工具输入
  • 处理 ui/notifications/tool-result,读取原始工具结果
  • 通过 tools/call 调用 app-visible 工具
  • 通过 ui/notifications/size-changed 通知高度变化
  • 避免直接调用 Xpert 后端 API
bridge 消息结构、历史回放和初始工具结果的大小限制请参考 ChatKit MCP Apps。新的 App 应读取标准 CallToolResult 形状的 ui/notifications/tool-result.params.contentparams.structuredContent;如果需要兼容旧版本,可以临时 fallback 到 params.result。App 也应能处理没有初始 tool-result 的情况:旧历史消息或超过宿主内联阈值的大结果可能只收到 tool-input,此时应展示摘要、提示重新运行,或通过 app-visible 工具按需重新加载/分页读取数据。当 App 超过小型 demo 的复杂度后,建议像普通前端项目一样维护源码,并把 MCP resource 作为构建产物的交付机制。

国际化

插件 App 应根据 ui/initialize 返回值在 iframe 内部完成 UI 本地化。ChatKit 会提供 hostContext.localehostContext.languagehostContext.direction,并在 App 运行前设置 HTML 文档的 lang / dir 属性。MCP 工具结果尽量保持结构化和语言中立,再由 src/app/main.ts 或前端框架负责格式化标签、摘要、数字、日期、图表标题和校验消息。

主题和样式

ChatKit 会在 MCP App iframe 内注入公共 CSS 变量,完整变量表和示例见 ChatKit MCP Apps 的主题变量。插件 App 应使用通用 --mcp-app-* 变量来定义背景、文字、边框、按钮、圆角和字体,不要依赖 ChatKit 内部 class 或私有 token;其他 MCP Apps host 也可以复用这一契约。业务图表的数据系列色可以由 App 自己定义语义色板,避免把宿主的弱化 UI 色或灰阶 token 直接用于柱、线、点。
body {
  font-family: var(--mcp-app-font-sans, system-ui, sans-serif);
  color: var(--mcp-app-color-foreground, #0f172a);
  background: var(--mcp-app-color-background, #fff);
}

.chart-panel {
  background: var(--mcp-app-color-card, #fff);
  border: 1px solid var(--mcp-app-color-border, #e2e8f0);
  border-radius: var(--mcp-app-radius, 8px);
}
如果使用 ECharts、Mapbox、Monaco 等需要 JavaScript 主题对象的库,可以在 App 初始化后通过 getComputedStyle(document.documentElement) 读取这些变量,或从 ui/initialize 返回的 hostContext.themeCssVariables 读取同一份值。 如果 App 加载 CDN 资源,需要在 resource _meta.ui.csp.resourceDomains 中声明域名。如果 App 需要浏览器侧网络请求,需要在 resource _meta.ui.csp.connectDomains 中声明目标域;更推荐通过 resources/readtools/call 走宿主代理访问。

构建和安装

构建插件,并确认发布包中包含 dist/mcp-server.js.xpertai-plugin/plugin.json
pnpm nx build @acme/plugin-sales-mcp-app
在 Xpert 中安装或重新加载插件,然后初始化插件资源,让 Xpert 创建 plugin-managed MCP Toolset。把该 Toolset 挂到 agent 上,再在 ChatKit 中请求交互式结果。 本地开发时要注意:源码包重新构建后,Xpert 插件运行目录中的已安装副本不一定会自动更新。修改后需要重新安装或重新加载插件,确保运行副本拿到最新的 dist 文件和 manifest。

测试清单

发布前建议覆盖:
  • stdio server 能启动,并且普通日志不写入 stdout
  • App 构建产物 dist/app/index.html 已生成,并包含 MCP Apps bridge 脚本
  • tools/list 返回模型可见工具,并排除 app-only 工具
  • 模型可见工具结果包含 _meta.ui.resourceUri
  • MCP App resource 返回 text/html;profile=mcp-app
  • MCP App resource metadata 包含需要的 _meta.ui.csp_meta.ui.permissionsprefersBorder
  • structuredContent 结构符合 iframe 预期
  • app-only 工具包含 _meta.ui.visibility = ['app']
  • Toolset policy 启用了 App 需要的所有工具
  • iframe 能初始化、接收原始工具输入和工具结果、调用 app-only 工具并调整高度
  • 外部脚本和连接域名已写入 resource CSP metadata
  • 插件重装后不会持久化旧的 @runtime__... 绝对路径

故障排查

现象可能原因修复方式
Cannot find module ... @runtime__...持久化的 MCP schema 中记录了旧安装运行目录plugin.json 中使用 ${PLUGIN_ROOT},重新构建插件,并重新安装或初始化旧插件资源。
MCP server 立即退出缺少构建后的 dist/mcp-server.js、ESM/CJS 配置错误,或 server setup 阶段异常本地执行 node dist/mcp-server.js 并查看 stderr;确认 package.json 中的 typeexportsfiles
app-only 工具仍出现在模型工具列表缺少 _meta.ui.visibility = ['app'],或宿主没有保留 _meta为工具设置 visibility,并检查生成的 Toolset metadata。
App 能加载但 tools/call 失败目标工具不是 app-visible,或被 Toolset policy 禁用添加 app visibility,并在 mcpServers.*.policy.enabledTools 中启用该工具。
CDN 脚本被阻止Resource CSP metadata 没有包含 CDN 域名把域名加入 resource _meta.ui.csp.resourceDomains

相关文档