前端Skill能力建模与MCP协议实战指南

发布时间:2026/10/25 10:02:32
前端Skill能力建模与MCP协议实战指南

1. 项目概述:一个前端工程师的“技能栈”与“能力协议”双线进化实录

我干前端这行快八年了,从 jQuery 时代切图仔一路走到现在写 React 微前端、搭 Playwright 自动化流水线、对接 AI Agent Skill 接口的“全栈式前端”。最近半年,我明显感觉到一个变化:光会写组件、调 API、搞性能优化,已经不够用了。团队里开始频繁出现两个词——Skill和MCP,它们不是新造的黑话,而是真实嵌入到我们日常开发流程里的新能力单元。比如上周,产品提了个需求:“用户在 H5 直播页点击‘实时字幕’按钮,要自动调用讯飞语音转写 SDK,并把流式结果渲染成滚动字幕,同时支持断网重连和低延迟缓冲。”——这个需求拆解下来,前端要做的远不止 DOM 操作:得封装一个可复用的SpeechToTextSkill,定义它的输入 Schema(音频流/设备 ID/语言)、输出 Schema(文本片段/时间戳/置信度)、执行生命周期(init → start → pause → resume → destroy),还得通过 MCP 协议跟后端调度服务通信,告诉它“我这边准备好了,可以下发语音流了”。

这就是标题里“在职前端 Skill 和 MCP 分享”的真实背景:它不是讲概念,不是画大饼,而是我在真实业务场景中,把 Skill 当作可插拔的原子能力模块、把 MCP 当作跨系统能力调用的统一契约,一步步落地的过程。核心关键词前端是领域锚点,Skill是能力封装范式,MCP是通信协议标准,而playwright-cli和ego-browser则是我在自动化测试和浏览器环境模拟中实际用到的工具链。你不需要是架构师,只要写过 React/Vue、跑过 E2E 测试、配过 Webpack,就能看懂这里每一步为什么这么选、踩过哪些坑、怎么绕过去。这篇文章就是我整理的“在职升级手记”,不讲虚的,只讲我每天打开 IDE 后真正在敲的代码、改的配置、调的接口。

2. Skill 是什么?不是插件,不是 SDK,而是一种前端能力建模方法

2.1 从“函数封装”到“Skill 建模”:一次认知升级

刚听到 Skill 这个词时,我第一反应是“哦,又是个插件”。但很快发现不对劲。我们之前写的utils/date.js里有个formatDate函数,它接受 date 和 format 字符串,返回格式化后的字符串——这叫工具函数;我们接入的aliyun-oss-sdk,提供putObject、getObject等方法操作对象存储——这叫 SDK;而 Skill 的定位完全不同。它要求你明确回答三个问题:

  • 这个能力的边界在哪?
    比如“直播字幕”Skill,它的输入只能是音频流或麦克风设备 ID,输出只能是带时间戳的文本片段。它不负责 UI 渲染(那是组件的事),也不负责网络重试(那是底层 SDK 的事),更不负责权限申请(那是 App Shell 的事)。它的职责被严格限定在“语音转文本”这一件事上。

  • 这个能力的生命周期如何管理?
    它必须有init()(初始化资源,如加载 WASM 模型)、start()(开始监听音频)、pause()(暂停但保持上下文)、destroy()(释放内存、关闭 WebSocket)。这比一个普通函数复杂得多,但它让能力具备了状态可管理、可中断、可复用的特性。

  • 这个能力如何被外部发现和调用?
    它不能靠import { SpeechToTextSkill } from './skills/speech-to-text'这种硬编码路径调用。它需要注册到一个全局 Skill Registry,暴露一个标准化的元信息(name: "speech-to-text", version: "1.2.0", inputSchema: {...}, outputSchema: {...}),让任何系统(前端页面、Playwright 脚本、甚至桌面 Electron 应用)都能通过SkillRegistry.get('speech-to-text')动态获取并调用。

提示:Skill 不是技术栈,而是一种设计契约。它强制你把“功能”从“代码”中抽象出来,变成可描述、可验证、可替换的实体。就像乐高积木,每一块都有标准凸点和凹槽,拼装时只关心接口,不关心内部怎么造。

2.2 Skill 编码规范实战:以skill-encoding-193为例

网络热词里反复出现的skill编码193、skill编码247,其实是内部 Skill 注册中心分配的唯一 ID。我们团队约定:所有 Skill 必须遵循一套轻量级编码规范,确保跨项目复用。以skill-encoding-193(即“GIS 空间分析 Skill”)为例,它的目录结构和关键文件如下:

src/skills/gis-analyze/ ├── index.ts // Skill 主入口,导出 Skill 类 ├── schema.ts // 输入/输出 Schema 定义(Zod 验证) ├── worker/ // 独立 Worker 线程,执行 CPU 密集计算 │ ├── main.ts // Worker 入口 │ └── algorithms/ // 核心算法(如缓冲区分析、叠加分析) ├── utils/ // 辅助工具(坐标系转换、GeoJSON 处理) └── test/ // 单元测试 + Playwright E2E 测试

核心index.ts实现非常精简:

// src/skills/gis-analyze/index.ts import { Skill, SkillContext } from '@frontend/skill-core'; import { analyzeBufferZone } from './worker/main'; import { InputSchema, OutputSchema } from './schema'; export class GISAnalyzeSkill extends Skill { static readonly id = 'skill-encoding-193'; static readonly name = 'gis-buffer-analysis'; static readonly version = '1.0.0'; // 定义输入输出 Schema,供运行时校验 inputSchema = InputSchema; outputSchema = OutputSchema; async init(context: SkillContext) { // 初始化:预加载 WebAssembly 模块,建立 Worker 通信通道 this.worker = new Worker(new URL('./worker/main.ts', import.meta.url)); this.worker.onmessage = (e) => { this.emit('output', e.data); }; } async start(input: z.infer<typeof InputSchema>) { // 校验输入 const validated = InputSchema.parse(input); // 发送任务到 Worker this.worker.postMessage({ type: 'ANALYZE_BUFFER', payload: validated }); } async destroy() { this.worker?.terminate(); } }

注意几个关键点:

  • SkillContext是 Skill 的“上下文容器”,里面包含logger、config、eventBus等通用能力,避免每个 Skill 重复造轮子;
  • emit('output', data)是 Skill 内部事件总线,外部通过skill.on('output', handler)订阅,实现解耦;
  • Worker 封装是必须的。GIS 计算极其耗 CPU,直接在主线程跑会导致页面卡死。我们约定:所有 CPU 密集型 Skill 必须用 Worker 隔离。

2.3 Skill 与前端 SDK 的本质区别:以“讯飞实时语音转写”为例

热词里提到“讯飞实时语音转写大模型前端适配”,这正是 Skill 思维落地的典型场景。讯飞官方 SDK 提供了createRecognizer()、start()、stop()等方法,但它是面向“调用者”的 SDK,不是面向“能力治理”的 Skill。我们做了三层封装:

  1. 第一层:SDK 封装层(xf-speech-sdk-wrapper.ts)
    屏蔽原生 SDK 的浏览器兼容性差异(如 Safari 对 Web Audio API 的限制)、错误码映射(把ERR_NO_PERMISSION统一转为PermissionDeniedError)、基础重试逻辑。

  2. 第二层:Skill 适配层(speech-to-text.skill.ts)
    实现Skill抽象类,将 SDK 方法映射为 Skill 生命周期:init()→createRecognizer(),start()→start(),destroy()→stop()+destroy()。重点是定义inputSchema:

    export const InputSchema = z.object({ deviceId: z.string().optional(), // 麦克风设备 ID language: z.enum(['zh-CN', 'en-US']).default('zh-CN'), enablePunctuation: z.boolean().default(true), maxDurationMs: z.number().min(1000).max(60000).default(30000), // 单次最长识别时长 });
  3. 第三层:业务组件层(LiveSubtitle.vue)
    在 Vue 组件中,不再直接 import SDK,而是:

    import { SkillRegistry } from '@frontend/skill-registry'; const speechSkill = await SkillRegistry.get('skill-encoding-194'); // 讯飞 Skill ID speechSkill.on('output', (data) => { this.subtitles.push(data.text); }); speechSkill.start({ deviceId: this.selectedDeviceId });

这样做的好处立竿见影:当某天讯飞 SDK 升级导致 API 变更,我们只需修改第二层speech-to-text.skill.ts,所有使用它的组件(直播页、会议页、教育课件页)完全无感;如果要切换成百度语音 SDK,只需写一个新的baidu-speech.skill.ts,注册为skill-encoding-247,业务组件一行代码都不用改。

3. MCP 是什么?不是 RPC,不是 REST,而是前端能力的“服务发现协议”

3.1 MCP 的诞生背景:当 Skill 多到需要“找得到、调得对、管得住”

Skill 解决了“能力怎么封装”的问题,但没解决“能力怎么找、怎么调、怎么管”的问题。想象一下:一个大型中台系统,集成了 30+ 个 Skill(GIS 分析、OCR 识别、语音转写、PDF 渲染、3D 模型加载……),它们分散在不同 Git 仓库、不同 NPM 包、甚至不同团队维护。前端页面想用“PDF 渲染 Skill”,它该 import 哪个路径?版本该用 1.2.0 还是 2.0.0?这个 Skill 是否支持当前浏览器?有没有权限调用?这些信息如果靠文档或口头约定,不出三个月就乱成一锅粥。

MCP(Model Capability Protocol)就是为了解决这个“能力治理”问题而设计的轻量级协议。它不是替代 HTTP 或 WebSocket 的传输层协议,而是一套运行时能力发现与协商的标准。它的核心思想是:每个 Skill 在注册时,必须向 MCP 中心上报自己的元数据;每个调用方在调用前,必须先向 MCP 中心查询该 Skill 的可用性、兼容性、授权策略。

我们团队的 MCP 中心是一个极简的前端服务(纯 JS,无后端依赖),部署在 CDN 上,URL 是https://mcp.example.com/v1。它只做三件事:

  • GET /skills:返回所有已注册 Skill 的列表(含 ID、名称、版本、浏览器兼容性、所需权限);
  • POST /skills/{id}/check:传入当前环境信息(UserAgent、Feature Detection 结果、用户角色),返回该 Skill 在当前环境是否可用、需授权哪些权限;
  • POST /skills/{id}/invoke:真正的调用入口,MCP 中心负责路由、鉴权、日志、熔断。

3.2 MCP 协议详解:从playwright-cli到ego-browser的全链路验证

热词里提到playwright-cli和ego-browser,它们正是我们验证 MCP 协议的关键工具。Playwright 不仅用于 E2E 测试,更是我们自动化 Skill 集成测试的主力;ego-browser 是一个高度可定制的 Chromium 实例,用来模拟各种受限浏览器环境(如无摄像头、无麦克风、WebGL 关闭)。

3.2.1 MCP 的 Skill 注册流程

一个 Skill 要被 MCP 中心发现,必须在构建时生成mcp-manifest.json文件:

{ "id": "skill-encoding-193", "name": "gis-buffer-analysis", "version": "1.0.0", "description": "基于 Turf.js 的 GIS 缓冲区分析能力", "entry": "./dist/skills/gis-analyze/index.js", "browser": { "minVersion": "chrome/95", "features": ["webgl", "wasm"] }, "permissions": ["geolocation"], "inputSchema": { "$ref": "./schema/input.json" }, "outputSchema": { "$ref": "./schema/output.json" } }

构建脚本(Vite 插件)会自动扫描src/skills/**/mcp-manifest.json,打包时将其注入到最终产物中,并向 MCP 中心发起注册请求(POST /register)。注册成功后,该 Skill 才会出现在/skills列表里。

3.2.2 前端调用 MCP 的标准流程

业务组件调用 Skill 的代码,不再是直接new Skill(),而是走 MCP 流程:

// src/composables/useSkill.ts import { MCPClient } from '@frontend/mcp-client'; const mcp = new MCPClient('https://mcp.example.com/v1'); export async function useGISAnalyze() { // Step 1: 查询 Skill 可用性 const checkResult = await mcp.check('skill-encoding-193', { userAgent: navigator.userAgent, features: { webgl: true, wasm: true }, permissions: { geolocation: 'granted' } }); if (!checkResult.available) { throw new Error(`Skill not available: ${checkResult.reason}`); } // Step 2: 获取 Skill 实例(MCP 中心返回一个代理对象) const skill = await mcp.get('skill-encoding-193'); // Step 3: 调用(MCP 代理会处理加载、初始化、错误重试) return skill.invoke({ geometry: { type: 'Point', coordinates: [116.4, 39.9] }, radiusMeters: 500 }); }

这个mcp.get()返回的不是原始 Skill 实例,而是一个MCPProxy对象。它内部做了:

  • 按需动态 import Skill 代码(Code Splitting);
  • 自动调用init()并等待完成;
  • 将invoke()参数按inputSchema校验;
  • 捕获异常并按 MCP 标准格式返回({ code: 'SKILL_EXECUTION_ERROR', message: '...', details: { ... } });
  • 记录调用日志(用于后续分析 Skill 使用率、失败率)。
3.2.3playwright-cli如何驱动 MCP 自动化测试

我们用 Playwright 编写了一套 MCP 自动化测试套件,核心命令是playwright-cli mcp-test --skill-id skill-encoding-193。它的工作流程是:

  1. 启动一个 ego-browser 实例,预设环境(如禁用 WebGL、模拟低网速);
  2. 访问 MCP 中心/skills接口,确认目标 Skill 存在;
  3. 调用/skills/{id}/check,传入 ego-browser 的模拟环境参数,验证兼容性判断是否正确;
  4. 加载一个最小化测试页面,该页面只包含mcp.get()和invoke()调用;
  5. 截图、录屏、捕获 console 日志,验证 Skill 执行结果是否符合预期(如 GIS 分析返回的 GeoJSON 是否有效);
  6. 生成 HTML 报告,标注每个环境下的测试通过/失败状态。

这套测试让我们在发布新 Skill 版本前,能快速验证它在 Chrome/Firefox/Safari/Edge 以及各种受限环境下的表现,避免“本地 OK,上线炸锅”的尴尬。

4. 实操:从零搭建一个可运行的 Skill + MCP 前端工作流

4.1 环境准备与工具链选型

我们不追求“最新最潮”,只选稳定、易调试、社区支持好的组合。以下是经过半年生产验证的工具链:

工具版本选型理由替代方案(不推荐原因)
构建工具Vite 5.2极快的冷启动、优秀的 HMR、内置 TypeScript 支持;插件生态成熟(如vite-plugin-mcp)Webpack(配置复杂,HMR 慢)、Rollup(缺乏开箱即用的 dev server)
Skill 核心库@frontend/skill-core1.3.0我们自研的轻量级 Skill 基类库(<5KB),无外部依赖,TypeScript 优先@microsoft/skills(太重,绑定 Azure 生态)、ai-skill-sdk(文档差,更新慢)
MCP 客户端@frontend/mcp-client0.8.0纯前端实现,支持离线缓存、重试、超时控制;API 设计简洁自己手写 fetch(易出错,缺少标准错误处理)
测试框架Playwright 1.42跨浏览器、支持真实设备模拟、API 稳定;playwright-cli可扩展性强Cypress(不支持多 tab、对 iframe 支持弱)、Jest(无法测试真实浏览器行为)
浏览器模拟ego-browser 0.5.1基于 Chromium,可精确控制 feature flags(如--disable-webgl)、权限(--unsafely-disable-web-security)Puppeteer(配置繁琐,对非标准 flag 支持差)

注意:ego-browser不是公开 npm 包,而是我们基于 Chromium 定制的二进制。它通过playwright-cli的--browser-channel参数指定路径。如果你没有定制需求,直接用 Playwright 内置的 Chromium 即可,效果一样。

4.2 创建第一个 Skill:skill-encoding-194(讯飞语音转写)

我们以热词“讯飞实时语音转写大模型前端适配”为蓝本,创建一个可立即运行的 Skill。

Step 1:初始化 Skill 目录

mkdir -p src/skills/speech-to-text cd src/skills/speech-to-text npm init -y # 安装讯飞 SDK(假设已获得授权) npm install iflytek-speech-sdk

Step 2:编写 Schema(schema.ts)

import { z } from 'zod'; export const InputSchema = z.object({ deviceId: z.string().optional(), language: z.enum(['zh-CN', 'en-US']).default('zh-CN'), sampleRate: z.number().int().min(8000).max(48000).default(16000), // 讯飞要求:必须指定采样率,否则识别不准 }); export const OutputSchema = z.object({ text: z.string(), timestamp: z.number().int(), // 毫秒级时间戳 confidence: z.number().min(0).max(1).default(0.8), isFinal: z.boolean().default(false), // 是否为最终结果(流式识别中可能为 false) });

Step 3:实现 Skill 主体(index.ts)

import { Skill, SkillContext } from '@frontend/skill-core'; import { InputSchema, OutputSchema } from './schema'; import { IFlyTekRecognizer } from 'iflytek-speech-sdk'; export class SpeechToTextSkill extends Skill { static readonly id = 'skill-encoding-194'; static readonly name = 'iflytek-speech-to-text'; static readonly version = '1.0.0'; inputSchema = InputSchema; outputSchema = OutputSchema; private recognizer: IFlyTekRecognizer | null = null; async init(context: SkillContext) { // 初始化讯飞 SDK(需配置 appid、apiKey 等) this.recognizer = new IFlyTekRecognizer({ appid: context.config.appid, apiKey: context.config.apiKey, apiSecret: context.config.apiSecret, // 这些配置从 MCP 中心下发,或从环境变量读取 }); } async start(input: z.infer<typeof InputSchema>) { const validated = InputSchema.parse(input); // 讯飞 SDK 的 start 方法返回 Promise<void>,但实际是异步事件驱动 this.recognizer!.start({ deviceId: validated.deviceId, language: validated.language, sampleRate: validated.sampleRate, }); // 监听讯飞 SDK 的事件 this.recognizer!.onResult = (result) => { const output = { text: result.text, timestamp: Date.now(), confidence: result.confidence, isFinal: result.isFinal, }; // 校验输出 Schema OutputSchema.parse(output); this.emit('output', output); }; this.recognizer!.onError = (error) => { this.emit('error', error); }; } async destroy() { this.recognizer?.stop(); this.recognizer = null; } }

Step 4:生成 MCP Manifest(mcp-manifest.json)

{ "id": "skill-encoding-194", "name": "iflytek-speech-to-text", "version": "1.0.0", "description": "讯飞语音转写 Skill,支持流式识别", "entry": "./dist/skills/speech-to-text/index.js", "browser": { "minVersion": "chrome/85", "features": ["webaudio", "mediarecorder"] }, "permissions": ["microphone"], "inputSchema": "./schema/input.json", "outputSchema": "./schema/output.json" }

Step 5:注册到 MCP 中心在vite.config.ts中添加插件:

import { mcpPlugin } from '@frontend/vite-plugin-mcp'; export default defineConfig({ plugins: [ mcpPlugin({ mcpCenterUrl: 'https://mcp.example.com/v1', // 构建时自动上传 manifest 并注册 }) ] });

运行npm run build,插件会自动将mcp-manifest.json发送到 MCP 中心完成注册。

4.3 在业务页面中调用 Skill

以一个直播 H5 页面为例(src/views/LivePage.vue):

<template> <div class="live-container"> <video ref="videoRef" autoplay muted /> <button @click="toggleSubtitles">开启实时字幕</button> <div class="subtitles"> <div v-for="item in subtitles" :key="item.timestamp" class="subtitle-item"> {{ item.text }} </div> </div> </div> </template> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; import { useSkill } from '@/composables/useSkill'; const videoRef = ref<HTMLVideoElement | null>(null); const subtitles = ref<Array<{ text: string; timestamp: number }>>([]); let speechSkill: any = null; const toggleSubtitles = async () => { if (!speechSkill) { try { // 通过 MCP 获取 Skill speechSkill = await useSkill('skill-encoding-194'); // 订阅输出事件 speechSkill.on('output', (data: any) => { subtitles.value.push({ text: data.text, timestamp: data.timestamp }); // 保持最多 5 条字幕滚动 if (subtitles.value.length > 5) { subtitles.value.shift(); } }); // 开始识别(传入麦克风设备 ID) await speechSkill.invoke({ deviceId: 'default', // 实际项目中应从 getUserMedia 获取 language: 'zh-CN', }); } catch (error) { console.error('Failed to start speech skill:', error); alert('字幕功能不可用,请检查麦克风权限'); } } else { // 停止识别 await speechSkill.destroy(); speechSkill = null; } }; onUnmounted(() => { if (speechSkill) { speechSkill.destroy(); } }); </script>

这段代码的关键在于:业务逻辑完全不知道讯飞 SDK 的存在,它只和 Skill ID、输入输出 Schema 打交道。如果未来换成百度语音,只需更换 MCP 中注册的skill-encoding-194实现,页面代码零修改。

4.4 Playwright 自动化测试实战

创建测试文件tests/speech-to-text.spec.ts:

import { test, expect } from '@playwright/test'; test('speech-to-text skill should work in chrome', async ({ page }) => { // Step 1: 访问 MCP 中心,确认 Skill 存在 const mcpResponse = await page.request.get('https://mcp.example.com/v1/skills'); const skills = await mcpResponse.json(); expect(skills.some((s: any) => s.id === 'skill-encoding-194')).toBe(true); // Step 2: 模拟环境检查 const checkResponse = await page.request.post( 'https://mcp.example.com/v1/skills/skill-encoding-194/check', { data: { userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36', features: { webaudio: true, mediarecorder: true }, permissions: { microphone: 'granted' } } } ); const checkResult = await checkResponse.json(); expect(checkResult.available).toBe(true); // Step 3: 加载测试页面(一个只调用 speech skill 的简单 HTML) await page.goto('http://localhost:5173/test-speech.html'); // Step 4: 触发字幕功能 await page.getByRole('button', { name: '开启实时字幕' }).click(); // Step 5: 等待并验证字幕出现(模拟 1 秒后收到第一条结果) await expect(page.locator('.subtitle-item')).toHaveCount(1); await expect(page.locator('.subtitle-item').first()).toContainText('你好'); });

运行npx playwright test tests/speech-to-text.spec.ts,即可看到完整的测试过程。我们每天 CI 流水线都会跑这套测试,确保所有 Skill 在主流浏览器下 100% 可用。

5. 常见问题与避坑指南:那些只有亲手踩过才懂的细节

5.1 Skill 生命周期陷阱:init()时机与资源泄漏

问题现象:
在直播页面中,用户频繁开关字幕功能,一段时间后页面内存占用飙升,Chrome DevTools 显示大量IFlyTekRecognizer实例未被释放。

根本原因:
我们最初在init()中直接new IFlyTekRecognizer(),但destroy()里只调用了recognizer.stop(),没有调用recognizer.destroy()。讯飞 SDK 的destroy()方法会清理所有事件监听器和内部定时器,而stop()只是暂停识别。

解决方案:

  • 强制约定:所有 Skill 的destroy()方法必须做两件事:1) 调用底层 SDK 的destroy();2) 将实例引用置为null。
  • 增加防御性检查:在init()开头加if (this.recognizer) this.destroy();,防止重复初始化。
  • Playwright 测试覆盖:在自动化测试中加入内存泄漏检测,用page.evaluate(() => performance.memory.usedJSHeapSize)对比开关 10 次前后的内存值。

实操心得:Skill 的init()和destroy()不是可选的,而是必须严格配对的“资源守门员”。我们后来在@frontend/skill-core库里加了一个ResourceGuardmixin,自动帮你 track 所有new XXX()的实例,并在destroy()时统一清理,省去大量 boilerplate。

5.2 MCP 兼容性检查失效:Feature Detection 的坑

问题现象:
MCP 中心返回available: true,但实际调用 Skill 时抛出NotSupportedError: The request is not supported.。

排查过程:
我们发现ego-browser模拟的navigator.mediaDevices对象,其enumerateDevices()方法返回的deviceId是空字符串,而讯飞 SDK 在start()时会尝试用这个空 ID 初始化音频源,导致失败。

根本原因:
MCP 的check接口只检查了mediarecorder: true这个布尔值,但没检查navigator.mediaDevices的具体能力。Feature Detection 不能只看 API 存在,还要看 API 的实际行为。

解决方案:

  • 细化 Feature Schema:在mcp-manifest.json中,browser.features改为对象形式:
    "features": { "mediarecorder": { "required": true, "test": "navigator.mediaDevices?.getUserMedia" }, "webaudio": { "required": true, "test": "window.AudioContext" } }
  • MCP 中心增强检测逻辑:/check接口收到请求后,不仅检查navigator属性,还会在沙箱环境中执行test字段的代码片段,返回真实结果。
  • 前端兜底:在useSkill()的checkResult后,加一层await navigator.mediaDevices.getUserMedia({ audio: true })的实际调用,失败则降级提示。

5.3 Playwright 测试中的权限模拟:ego-browser的正确用法

问题现象:
playwright-cli mcp-test在 CI 环境中总是失败,报错Permission denied for microphone。

原因分析:
CI 服务器是无界面的 Linux 环境,Playwright 默认启动的 Chromium 无法访问真实麦克风。我们误以为ego-browser的--unsafely-disable-web-security参数能绕过权限,但实际上它只禁用 CORS,不解决媒体设备权限。

正确做法:

  • 使用虚拟音频设备:在 CI 服务器上安装pavucontrol和pulseaudio,创建一个null-sink作为虚拟麦克风:
    pactl load-module module-null-sink sink_name=virtual_mic
  • Playwright 启动参数:
    const browser = await chromium.launch({ args: [ '--use-fake-ui-for-media-stream', // 关键!让浏览器自动授予媒体权限 '--use-fake-device-for-media-stream', // 使用虚拟设备 '--virtual-mic-device=virtual_mic' // 指定虚拟设备名 ] });
  • 测试代码中显式请求权限:
    await page.evaluate(async () => { // 在页面上下文中请求权限,避免跨域问题 await navigator.permissions.query({ name: 'microphone' }); });

5.4 Skill 版本冲突:当多个页面同时加载不同版本的同一 Skill

问题现象:
A 页面加载了skill-encoding-194@1.0.0,B 页面加载了skill-encoding-194@1.1.0,两者共存时,B 页面的invoke()调用偶尔会触发 A 页面的onOutput回调。

根因:
我们最初用window.SkillRegistry作为全局单例,所有 Skill 实例都注册到同一个对象上。当mcp.get('skill-encoding-194')被多次调用时,它返回的是同一个 Skill 实例(因为id相同),但不同版本的 Skill 类可能有不同事件总线实现,导致事件混杂。

终极解法:

  • MCP 代理层隔离:mcp.get()不再返回原始 Skill 实例,而是返回一个MCPProxy,每个MCPProxy拥有独立的事件总线和状态。
  • 版本感知加载:mcp.get('skill-encoding-194@1.1.0')和mcp.get('skill-encoding-194@1.0.0')会加载不同的代码包,即使 ID 相同,也视为不同 Skill。
  • 强制版本语义化:在mcp-manifest.json中,id字段改为id: "skill-encoding-194@1.1.0",彻底杜绝歧义。

这个坑我们踩了整整两周。教训是:Skill 的 ID 必须包含版本号,或者 MCP 中心必须支持版本路由。没有版本的 Skill,就像没有版本的 API,注定混乱。

5.5 前端防爬与 Skill 安全:如何保护你的 Skill 不被滥用

热词里提到“关于前端的一些安全技术操作,怎么在前端进行防止爬虫,防止查看页面源码”,这和 Skill 安全直接相关。Skill 封装了业务核心逻辑(如 GIS 分析、语音转写),如果被恶意爬虫调用,会造成 API 配额耗尽或敏感数据泄露。

我们的防护组合拳:

  1. MCP 层鉴权:所有POST /skills/{id}/invoke请求,必须携带Authorization: Bearer <JWT>,JWT 由后端签发,包含用户身份、Skill ID、有效期。
  2. Skill 内部校验:在invoke()方法开头,解析 JWT,验证aud(受众)是否为当前 Skill ID,exp是否过期。
  3. 前端混淆:用esbuild的--minify和--tree-shaking,配合javascript-obfuscator对 Skill 代码进行轻量混淆(不追求绝对安全,只为