Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 57 additions & 20 deletions docs/ai-extension/next-wxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,49 @@

通过原生 `document.modelContext.registerTool` API,开发者可以极低成本地将前端页面的业务能力暴露给 AI 助手,实现"大模型直接操作业务后台"。

## 一、工作原理
有两条互补路径:

| 路径 | 适用 | 说明 |
|---|---|---|
| **页面 MCP 脚本(推荐推广)** | 终端用户 / 快速适配新站 | Options →「页面 MCP 脚本」在线编辑,按 `@match` 匹配站点,保存后即时注入 |
| **源码内置 mcp-servers** | 扩展维护者 / 随包分发 | 在 `packages/next-wxt/mcp-servers/<hostname>/` 编写 TS,构建期打成 IIFE |

二者并行:默认同时生效;用户脚本勾选「覆盖内置」且匹配当前页时,跳过该页内置域名脚本。

## 〇、页面 MCP 脚本(在线编辑)

1. 打开扩展 Options(配置页)→ **页面 MCP 脚本**。
2. 点击「新建脚本」,填写:
- **名称 / 描述**
- **@match**:每行一条,例如 `*://*.example.com/*`、`https://www.baidu.com/*`
- **源码**:纯 JS,在页面 MAIN world 调用 `document.modelContext.registerTool`
- **启用** / **覆盖内置**(可选)
3. 保存后,匹配的标签页会自动刷新并注入脚本;工具出现在侧栏「浏览器内置工具」中。
4. 支持 zip 导入/导出备份(解压后即为 `mcp-servers/<host>/{index.ts,meta.ts}`,与源码内置格式一致;兼容旧版 JSON)。

默认模板已包含幂等防护。示例片段:

```javascript
;(function () {
if (window.__userMcp_demo_registered) return
var ctx = document.modelContext
if (!ctx) return
ctx.registerTool({
name: 'demo_tool',
title: '示例',
description: '示例工具',
inputSchema: { type: 'object', properties: {} },
execute: async function () {
return { content: [{ type: 'text', text: document.title }] }
}
})
window.__userMcp_demo_registered = true
})()
```

实现位于独立模块 `packages/next-wxt/user-mcp-scripts/`,与 Skills、远程 MCP 市场解耦。

## 一、工作原理(源码内置)

开发一个网站原生工具,整体流程是这样的:

Expand All @@ -16,7 +58,7 @@

![工具注入流程](../assets/images/mermaid/next-wxt-workflow.svg)

## 二、开发步骤
## 二、开发步骤(源码内置 mcp-servers)

### 1. 创建域名目录

Expand All @@ -32,8 +74,8 @@
// packages/next-wxt/mcp-servers/example.com/index.ts

/**
* 此文件由 content script 通过 scripting.executeScript 注入到 example.com 的 JS 上下文中执行
* 拥有完整的页面执行权限,不受 CSP 限制
* 此文件由 content script 经 <script src> 注入到 example.com 的 MAIN world
* 拥有完整的页面执行权限。
*/

if ((document as any).modelContext) {
Expand Down Expand Up @@ -69,35 +111,30 @@ if ((document as any).modelContext) {

### 3. 配置扩展元数据 (`meta.ts`)

如果你需要定义此工具的辅助信息(如将其内置加入"插件市场"),可以在同目录下新建 `meta.ts`
域名注入门禁依赖同目录 `meta.ts`(文件夹名须与 `location.hostname` 完全一致)。可选字段示例:

```typescript
// packages/next-wxt/mcp-servers/example.com/meta.ts
export default {
// 如果你有专属的外部云端 Agent 或服务端 MCP,可以通过以下配置聚合到市场面板中
customMarketMcpServers: [
{
id: 'example-cloud-mcp',
name: 'Example 专属云端能力',
url: 'https://api.example.com/mcp/sse',
type: 'sse',
enabled: true
}
]
name: 'example.com',
description: '示例站点专属工具'
};
```

> **说明**:复杂的 `toolsJumpLinks` 或多页流程代理编排已不再推荐,建议将复杂流程直接下发到对应页面的单一 WebMCP 脚本中解决。
> **说明**:复杂的 `toolsJumpLinks` 或多页流程代理编排已不再推荐,建议将复杂流程直接下发到对应页面的单一 WebMCP 脚本中解决。远程 SSE 市场条目(`customMarketMcpServers`)与本页注入工具无关。

## 三、调试与验证

1. 在项目根目录运行 `pnpm dev:wxt`。
2. 打开浏览器并刷新目标页面(`example.com`)
3. 页面加载完成后打开控制台,或连接远程 Cursor Agent。由于发送了 `notifications/tools/list_changed`,你将立刻看到新注册的 `claim-coupon` 工具
4. 尝试向 Agent 发送对话:"帮我抢一张 50 元的优惠券",观察 Agent 调用情况与页面状态变更
2. **用户脚本**:Options 保存后刷新匹配页;**内置脚本**:改源码后由构建插件重新产出并刷新目标页
3. 页面加载完成后打开控制台,或连接远程 Cursor Agent。由于发送了 `notifications/tools/list_changed`,你将立刻看到新注册的工具
4. 尝试向 Agent 发送对话,观察工具调用与页面状态变更

## 四、最佳实践与注意事项

1. **优先直接调用业务逻辑**:如果页面基于 React/Vue,你可以在 `index.ts` 中通过 Fiber 树搜索,或在业务代码中显式挂载 `window.__MyApp` 供扩展调用,避免使用脆弱的 `document.querySelector().click()` 模拟点击。
1. **优先直接调用业务逻辑**:如果页面基于 React/Vue,你可以在脚本中通过 Fiber 树搜索,或在业务代码中显式挂载 `window.__MyApp` 供扩展调用,避免使用脆弱的 `document.querySelector().click()` 模拟点击。
2. **做好错误处理**:`execute` 函数中必须捕获所有可能抛出的错误,并转换为合法的 `content` 返回给模型,否则会导致模型调用链中断。
3. **参数描述要清晰**:`description` 与 `inputSchema` 是大模型判断是否使用工具以及如何传参的**唯一依据**,务必描述详尽。
4. **宽泛 @match + 覆盖内置**:例如 `*://*/*` 且开启「覆盖内置」会跳过所有内置域名工具,请谨慎使用。
5. **幂等注册**:重复注入时应用全局标记或同名覆盖策略,避免重复注册异常。
6. **严格 CSP 站点**(如京东):用户脚本经扩展桥 `vendor/user-mcp-exec.js`(background bind 能力令牌后执行)注入,勿依赖页面内 `eval`;保存后请刷新目标页再查看「浏览器内置工具」。
25 changes: 15 additions & 10 deletions packages/next-wxt/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TinyButton: (typeof import('@opentiny/vue-button'))['default']
TinyFormItem: (typeof import('@opentiny/vue-form-item'))['default']
TinyInput: (typeof import('@opentiny/vue-input'))['default']
TinyModal: (typeof import('@opentiny/vue-modal'))['default']
TinyRadio: (typeof import('@opentiny/vue-radio'))['default']
TinySelect: (typeof import('@opentiny/vue-select'))['default']
TinySwitch: (typeof import('@opentiny/vue-switch'))['default']
TinyTabItem: (typeof import('@opentiny/vue-tab-item'))['default']
TinyTabs: (typeof import('@opentiny/vue-tabs'))['default']
TinyTree: (typeof import('@opentiny/vue-tree'))['default']
TinyAlert: typeof import('@opentiny/vue-alert')['default']
TinyButton: typeof import('@opentiny/vue-button')['default']
TinyCollapse: typeof import('@opentiny/vue-collapse')['default']
TinyCollapseItem: typeof import('@opentiny/vue-collapse-item')['default']
TinyFormItem: typeof import('@opentiny/vue-form-item')['default']
TinyInput: typeof import('@opentiny/vue-input')['default']
TinyModal: typeof import('@opentiny/vue-modal')['default']
TinyRadio: typeof import('@opentiny/vue-radio')['default']
TinySelect: typeof import('@opentiny/vue-select')['default']
TinySwitch: typeof import('@opentiny/vue-switch')['default']
TinyTabItem: typeof import('@opentiny/vue-tab-item')['default']
TinyTabs: typeof import('@opentiny/vue-tabs')['default']
TinyTag: typeof import('@opentiny/vue-tag')['default']
TinyTooltip: typeof import('@opentiny/vue-tooltip')['default']
TinyTree: typeof import('@opentiny/vue-tree')['default']
VanIcon: typeof import('vant/es')['Icon']
}
}
51 changes: 50 additions & 1 deletion packages/next-wxt/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { useWebAgentServer, forceWebAgentReconnect } from './sidepanel/composable/useWebAgentServer'
import { tabHistory } from './background/tab-history'
import {
injectUserMcpScriptsForTab,
reloadTabsByMatchesSnapshot,
reinjectAfterUserMcpScriptsChange,
clearUserMcpBridgeToken
} from './background/inject-user-mcp-scripts'

export default defineBackground(() => {
// ─────────────────────────────────────────
Expand All @@ -15,10 +21,16 @@ export default defineBackground(() => {
})
}, 0)

// 页面导航 / 关闭后丢弃 capability,避免跨文档复用
browser.tabs.onRemoved.addListener((tabId) => clearUserMcpBridgeToken(tabId))
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'loading') clearUserMcpBridgeToken(tabId)
})

// ─────────────────────────────────────────
// 消息处理(需要返回值的使用原生 onMessage)
// ─────────────────────────────────────────
browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Web Agent 手动重连
// 注意:不依赖 initPromise,因为它可能已经 rejected(初始连接失败时),
// rejected 的 Promise 状态永久不变,会导致重连请求直接走 catch 分支而不实际发起连接。
Expand All @@ -42,6 +54,43 @@ export default defineBackground(() => {
.catch(() => sendResponse({ sessionId: '', status: 'error' }))
return true
}

// content:注入匹配的用户 MCP 脚本,并返回是否跳过内置 mcp-servers
if (message.type === 'inject-user-mcp-scripts') {
const tabId = message.tabId ?? sender.tab?.id
const url = message.url || sender.tab?.url
if (!tabId || !url) {
sendResponse({
success: false,
shouldSkipBuiltIn: false,
injectedCount: 0,
error: '缺少 tabId 或 url'
})
return true
}
injectUserMcpScriptsForTab(tabId, url)
.then((result) => sendResponse(result))
.catch((error) =>
sendResponse({
success: false,
shouldSkipBuiltIn: false,
injectedCount: 0,
error: error?.message || String(error)
})
)
return true
}

// Options:保存/删除后刷新匹配标签页
if (message.type === 'reinject-user-mcp-scripts') {
const run = message.matchesSnapshot
? reloadTabsByMatchesSnapshot(message.matchesSnapshot)
: reinjectAfterUserMcpScriptsChange(message.scriptId)
run
.then((reloaded) => sendResponse({ success: true, reloaded }))
.catch((error) => sendResponse({ success: false, error: error?.message || String(error) }))
return true
}
})

// ─────────────────────────────────────────
Expand Down
Loading
Loading