From 04371e0da6e086f2e2c85a898556bed78e63eafe Mon Sep 17 00:00:00 2001 From: icaca Date: Sun, 24 May 2026 10:42:45 +0800 Subject: [PATCH 01/34] feat(native-messaging): Add Native Messaging Host with MCP Server for external script management - Add NativeMessageHandler in Service Worker to handle Native Messaging connections - Supports 6 operations: list_scripts, get_script, install_script, uninstall_script, enable_script, disable_script - Proactively connects to native host on startup via chrome.runtime.connectNative() - Handles bidirectional message passing with proper error handling - Add packages/native-messaging-host: unified Native Host + MCP Server process - NativeHost: stdio protocol (4-byte LE length-prefixed JSON) for browser communication - MCP Server: HTTP+SSE transport on port 3333 for AI/CLI integration - Internal message bus via EventEmitter for bridging both protocols - Port conflict handling (EADDRINUSE graceful skip) - Add ScriptService.getScriptAndCode() for retrieving script metadata + source code - Add nativeMessaging permission to manifest.json - Add PROTOCOL.md: complete JSON message protocol documentation This enables external tools (AI assistants, CLI tools) to manage ScriptCat user scripts through the MCP protocol, while the native messaging bridge maintains secure communication with the browser extension. --- packages/native-messaging-host/PROTOCOL.md | 608 +++++++++++++++++++ packages/native-messaging-host/install.ps1 | 30 + packages/native-messaging-host/launch.js | 31 + packages/native-messaging-host/launch.mjs | 32 + packages/native-messaging-host/manifest.json | 9 + packages/native-messaging-host/package.json | 18 + packages/native-messaging-host/src/index.ts | 424 +++++++++++++ packages/native-messaging-host/tsconfig.json | 14 + src/app/service/service_worker/index.ts | 8 + src/app/service/service_worker/native_msg.ts | 198 ++++++ src/app/service/service_worker/script.ts | 4 + src/manifest.json | 5 +- 12 files changed, 1379 insertions(+), 2 deletions(-) create mode 100644 packages/native-messaging-host/PROTOCOL.md create mode 100644 packages/native-messaging-host/install.ps1 create mode 100644 packages/native-messaging-host/launch.js create mode 100644 packages/native-messaging-host/launch.mjs create mode 100644 packages/native-messaging-host/manifest.json create mode 100644 packages/native-messaging-host/package.json create mode 100644 packages/native-messaging-host/src/index.ts create mode 100644 packages/native-messaging-host/tsconfig.json create mode 100644 src/app/service/service_worker/native_msg.ts diff --git a/packages/native-messaging-host/PROTOCOL.md b/packages/native-messaging-host/PROTOCOL.md new file mode 100644 index 000000000..bfdbce778 --- /dev/null +++ b/packages/native-messaging-host/PROTOCOL.md @@ -0,0 +1,608 @@ +# ScriptCat Native Messaging 协议文档 + +## 1. 通信架构概览 + +ScriptCat 的 Native Messaging 系统采用三层通信架构: + +``` +AI 客户端 ←─ HTTP/SSE ──→ MCP Server (:3333) ←─ EventEmitter ──→ NativeHost ←─ stdio ──→ 浏览器扩展 +``` + +| 层级 | 组件 | 传输方式 | 说明 | +|------|------|----------|------| +| 外部接口 | MCP Server | HTTP + SSE(端口 3333) | 对 AI 客户端暴露 JSON-RPC 2.0 接口 | +| 内部总线 | EventEmitter | 进程内事件 | MCP Server 与 NativeHost 之间的进程内通信 | +| 浏览器通道 | NativeHost ↔ 浏览器扩展 | stdio(4 字节 LE 长度前缀 + JSON) | Chrome Native Messaging 标准协议 | + +**数据流向:** + +1. AI 客户端通过 HTTP POST 发送 JSON-RPC 请求到 MCP Server +2. MCP Server 将请求转换为 `NativeRequest`,通过 EventEmitter 传递给 NativeHost +3. NativeHost 通过 stdio 将 `NativeRequest` 发送给浏览器扩展 +4. 浏览器扩展的 `NativeMessageHandler` 处理请求,调用 `ScriptService` 执行操作 +5. 浏览器扩展通过 stdio 返回 `NativeResponse` +6. NativeHost 接收响应,通过 EventEmitter 传回 MCP Server +7. MCP Server 将结果格式化为 JSON-RPC 响应返回给 AI 客户端 + +--- + +## 2. Stdio JSON 协议格式 + +NativeHost 与浏览器扩展之间使用 Chrome Native Messaging 标准的 stdio 协议: + +### 编码规则 + +每条消息由 **4 字节小端序(Little-Endian)无符号整数长度前缀** + **UTF-8 编码的 JSON 正文**组成。 + +``` +┌──────────────────┬──────────────────────────────┐ +│ 长度前缀 (4字节) │ JSON 正文 (N 字节) │ +│ UInt32LE │ UTF-8 encoded │ +└──────────────────┴──────────────────────────────┘ +``` + +### 发送消息(NativeHost → 浏览器) + +```typescript +const json = JSON.stringify(request); +const lenBuf = Buffer.alloc(4); +lenBuf.writeUInt32LE(Buffer.byteLength(json, "utf-8"), 0); +fs.writeSync(1, lenBuf); // 写入 4 字节长度 +fs.writeSync(1, json); // 写入 JSON 正文 +``` + +### 接收消息(浏览器 → NativeHost) + +```typescript +// 1. 读取 4 字节长度前缀 +const bytesRead = fs.readSync(0, BUF, 0, 4, null); +const msgLen = BUF.readUInt32LE(0); + +// 2. 读取 msgLen 字节的 JSON 正文 +const data = Buffer.alloc(msgLen); +// ... 循环读取直到 offset === msgLen + +// 3. 解析 JSON +const msg = JSON.parse(data.toString("utf-8")); +``` + +### 限制 + +| 项目 | 值 | +|------|----| +| 最大消息大小 | 1 MB(1,048,576 字节) | +| 响应超时时间 | 30,000 毫秒(30 秒) | + +超过 1 MB 的消息将被丢弃并输出错误日志。 + +--- + +## 3. 通用消息结构 + +### 请求(NativeRequest) + +```typescript +interface NativeRequest { + id: string; // 请求唯一标识符,格式: "m_{timestamp}_{counter}" + type: NativeMessageType; // 消息类型 + data: Record; // 请求参数 +} +``` + +### 响应(NativeResponse) + +```typescript +interface NativeResponse { + id: string; // 对应请求的 id + ok: boolean; // 是否成功 + data?: unknown; // 成功时的返回数据 + error?: string; // 失败时的错误信息 +} +``` + +### 消息类型(NativeMessageType) + +```typescript +type NativeMessageType = + | "list_scripts" + | "get_script" + | "install_script" + | "uninstall_script" + | "enable_script" + | "disable_script"; +``` + +--- + +## 4. 消息类型详细说明 + +### 4.1 list_scripts + +列出所有已安装的用户脚本。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 请求 ID | +| type | string | 是 | 固定值 `"list_scripts"` | +| data | object | 是 | 空对象 `{}` | + +**请求示例** + +```json +{ + "id": "m_1716441600000_1", + "type": "list_scripts", + "data": {} +} +``` + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 请求 ID | +| ok | boolean | 固定 `true` | +| data | ScriptSummary[] | 脚本摘要列表 | + +**响应示例** + +```json +{ + "id": "m_1716441600000_1", + "ok": true, + "data": [ + { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "示例脚本", + "namespace": "https://example.com", + "version": "1.0.0", + "author": "developer", + "type": "normal", + "status": "enabled", + "enabled": true, + "updateUrl": "https://example.com/script.user.js", + "description": "这是一个示例脚本" + } + ] +} +``` + +--- + +### 4.2 get_script + +获取指定脚本的详细信息及源代码。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 请求 ID | +| type | string | 是 | 固定值 `"get_script"` | +| data.uuid | string | 是 | 脚本的 UUID | + +**请求示例** + +```json +{ + "id": "m_1716441600000_2", + "type": "get_script", + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } +} +``` + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 请求 ID | +| ok | boolean | 是否成功 | +| data | object | 脚本完整信息,包含 `code`(源代码)等字段 | +| error | string | 失败时的错误信息 | + +**响应示例** + +```json +{ + "id": "m_1716441600000_2", + "ok": true, + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "示例脚本", + "namespace": "https://example.com", + "code": "// ==UserScript==\n// @name 示例脚本\n// ==/UserScript==\nconsole.log('hello');", + "metadata": { + "name": ["示例脚本"], + "version": ["1.0.0"] + } + } +} +``` + +**错误示例** + +```json +{ + "id": "m_1716441600000_2", + "ok": false, + "error": "Script not found" +} +``` + +--- + +### 4.3 install_script + +安装用户脚本,支持通过 URL 或代码安装。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 请求 ID | +| type | string | 是 | 固定值 `"install_script"` | +| data.url | string | 否* | 脚本的 URL 地址 | +| data.code | string | 否* | 脚本的 JavaScript 代码 | +| data.existing_uuid | string | 否 | 已有脚本的 UUID,用于代码安装时的更新标识 | + +> *`url` 和 `code` 必须提供其中之一,否则将返回错误。 + +**通过 URL 安装 — 请求示例** + +```json +{ + "id": "m_1716441600000_3", + "type": "install_script", + "data": { + "url": "https://example.com/script.user.js" + } +} +``` + +**通过代码安装 — 请求示例** + +```json +{ + "id": "m_1716441600000_4", + "type": "install_script", + "data": { + "code": "// ==UserScript==\n// @name 新脚本\n// ==/UserScript==\nconsole.log('hello');" + } +} +``` + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 请求 ID | +| ok | boolean | 是否成功 | +| data.uuid | string | 安装后脚本的 UUID | +| data.name | string | 安装后脚本的名称 | +| data.update | boolean | 是否为更新操作(当前固定 `false`) | +| error | string | 失败时的错误信息 | + +**响应示例** + +```json +{ + "id": "m_1716441600000_3", + "ok": true, + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "示例脚本", + "update": false + } +} +``` + +**错误示例** + +```json +{ + "id": "m_1716441600000_3", + "ok": false, + "error": "Either 'url' or 'code' is required" +} +``` + +--- + +### 4.4 uninstall_script + +卸载指定的用户脚本。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 请求 ID | +| type | string | 是 | 固定值 `"uninstall_script"` | +| data.uuid | string | 是 | 要卸载脚本的 UUID | + +**请求示例** + +```json +{ + "id": "m_1716441600000_5", + "type": "uninstall_script", + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } +} +``` + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 请求 ID | +| ok | boolean | 是否成功 | +| data.uuid | string | 被卸载脚本的 UUID | +| data.removed | boolean | 固定 `true`,表示已移除 | +| error | string | 失败时的错误信息 | + +**响应示例** + +```json +{ + "id": "m_1716441600000_5", + "ok": true, + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "removed": true + } +} +``` + +**错误示例** + +```json +{ + "id": "m_1716441600000_5", + "ok": false, + "error": "'uuid' is required" +} +``` + +--- + +### 4.5 enable_script + +启用指定的用户脚本。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 请求 ID | +| type | string | 是 | 固定值 `"enable_script"` | +| data.uuid | string | 是 | 要启用脚本的 UUID | + +**请求示例** + +```json +{ + "id": "m_1716441600000_6", + "type": "enable_script", + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } +} +``` + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 请求 ID | +| ok | boolean | 是否成功 | +| data.uuid | string | 脚本的 UUID | +| data.enabled | boolean | 固定 `true`,表示已启用 | +| error | string | 失败时的错误信息 | + +**响应示例** + +```json +{ + "id": "m_1716441600000_6", + "ok": true, + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "enabled": true + } +} +``` + +--- + +### 4.6 disable_script + +禁用指定的用户脚本。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 请求 ID | +| type | string | 是 | 固定值 `"disable_script"` | +| data.uuid | string | 是 | 要禁用脚本的 UUID | + +**请求示例** + +```json +{ + "id": "m_1716441600000_7", + "type": "disable_script", + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } +} +``` + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 请求 ID | +| ok | boolean | 是否成功 | +| data.uuid | string | 脚本的 UUID | +| data.enabled | boolean | 固定 `false`,表示已禁用 | +| error | string | 失败时的错误信息 | + +**响应示例** + +```json +{ + "id": "m_1716441600000_7", + "ok": true, + "data": { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "enabled": false + } +} +``` + +--- + +## 5. ScriptSummary 数据结构 + +`list_scripts` 返回的每个脚本摘要包含以下字段: + +```typescript +interface ScriptSummary { + uuid: string; // 脚本唯一标识符 + name: string; // 脚本名称 + namespace: string; // 脚本命名空间 + version?: string; // 脚本版本(来自 metadata.version[0]) + author?: string; // 脚本作者(来自 metadata.author[0]) + type: string; // 脚本类型 + status: string; // 脚本状态 + enabled: boolean; // 是否启用(status === 1 时为 true) + updateUrl?: string; // 脚本更新地址(checkUpdateUrl) + description?: string; // 脚本描述(来自 metadata.description[0]) +} +``` + +### type 字段取值 + +| 内部数值 | 字符串值 | 说明 | +|----------|----------|------| +| 1 | `"normal"` | 普通网页脚本 | +| 2 | `"crontab"` | 定时脚本 | +| 3 | `"background"` | 后台脚本 | +| 其他 | `"unknown"` | 未知类型 | + +### status 字段取值 + +| 内部数值 | 字符串值 | 说明 | +|----------|----------|------| +| 1 | `"enabled"` | 已启用 | +| 2 | `"disabled"` | 已禁用 | +| 其他 | `"unknown"` | 未知状态 | + +--- + +## 6. 常见错误码与错误信息 + +Native Messaging 协议本身不使用数字错误码,而是通过 `ok: false` + `error` 字符串来传递错误。以下是常见的错误信息: + +| 错误信息 | 触发场景 | 相关消息类型 | +|----------|----------|-------------| +| `"Either 'url' or 'code' is required"` | 安装脚本时未提供 url 和 code | install_script | +| `"'uuid' is required"` | 操作需要 uuid 但未提供 | uninstall_script, enable_script, disable_script | +| `"Unknown message type: {type}"` | 请求的 type 不在已知类型中 | 任意 | +| `"Timeout waiting for browser response"` | 浏览器在 30 秒内未响应 | 任意(NativeHost 侧) | +| `"Script not found"` | 指定 UUID 的脚本不存在 | get_script, uninstall_script, enable_script, disable_script | + +### MCP 层 JSON-RPC 错误码 + +MCP Server 在 HTTP 接口层使用标准 JSON-RPC 2.0 错误码: + +| 错误码 | 说明 | +|--------|------| +| -32603 | 内部错误(服务端异常) | + +--- + +## 7. 连接生命周期 + +### 7.1 建立连接 + +1. **NativeHost 启动**:Node.js 进程启动,监听 stdin,等待浏览器连接 +2. **浏览器连接**:浏览器扩展通过 `chrome.runtime.connectNative()` 发起连接 +3. **连接建立**:`NativeMessageHandler` 监听 `chrome.runtime.onConnectNative` 事件,获取 `Port` 对象 +4. **MCP Server 就绪**:HTTP 服务器在 `127.0.0.1:3333` 上开始监听 + +### 7.2 消息交换 + +``` +NativeHost 浏览器扩展 + │ │ + │──── NativeRequest (4字节长度 + JSON) ────────→│ + │ │ 处理请求 + │←─── NativeResponse (4字节长度 + JSON) ───────│ + │ │ + │──── NativeRequest ──────────────────────────→│ + │ │ 处理请求 + │←─── NativeResponse ─────────────────────────│ + │ │ +``` + +**关键特性:** + +- 请求-响应模式:每个请求通过 `id` 字段与响应对应 +- NativeHost 使用 `resp_{id}` 事件名匹配响应 +- 响应超时时间为 30 秒,超时后 Promise 被 reject +- 消息大小上限为 1 MB + +### 7.3 断开连接 + +1. **浏览器断开**:浏览器关闭或扩展被禁用时,`Port.onDisconnect` 事件触发 +2. **NativeHost 检测**:stdin 读取返回 0 字节时,NativeHost 退出进程 +3. **清理资源**:`NativeMessageHandler` 将 `port` 置为 `null`,MCP Server 的后续请求将超时 + +### 7.4 MCP 客户端连接(HTTP + SSE) + +1. **SSE 连接**:客户端 GET `/sse` 建立事件流连接 +2. **获取端点**:服务端推送 `endpoint` 事件,包含消息提交 URI +3. **发送请求**:客户端 POST `/message` 发送 JSON-RPC 2.0 请求 +4. **接收响应**:服务端返回 JSON-RPC 2.0 响应 +5. **断开**:客户端关闭 SSE 连接,服务端从 `connectedClients` 中移除 + +### 7.5 MCP 初始化流程 + +``` +客户端 MCP Server + │ │ + │──── POST /message ──────────────────────────→│ + │ { "method": "initialize", ... } │ + │←─── Response ───────────────────────────────│ + │ { protocolVersion, capabilities, ... } │ + │ │ + │──── POST /message ──────────────────────────→│ + │ { "method": "notifications/initialized"} │ + │←─── 202 Accepted ──────────────────────────│ + │ │ + │──── POST /message ──────────────────────────→│ + │ { "method": "tools/list" } │ + │←─── Response { tools: [...] } ──────────────│ + │ │ + │──── POST /message ──────────────────────────→│ + │ { "method": "tools/call", params: {...} }│ + │←─── Response { content: [...] } ────────────│ +``` + +**MCP Server 信息:** + +| 项目 | 值 | +|------|----| +| 协议版本 | `2024-11-05` | +| 服务器名称 | `scriptcat` | +| 服务器版本 | `1.0.0` | +| 默认端口 | 3333(可通过 `SCRIPTCAT_MCP_PORT` 环境变量配置) | +| 监听地址 | `127.0.0.1` | diff --git a/packages/native-messaging-host/install.ps1 b/packages/native-messaging-host/install.ps1 new file mode 100644 index 000000000..89cf2d4b9 --- /dev/null +++ b/packages/native-messaging-host/install.ps1 @@ -0,0 +1,30 @@ +param( + [string]$ExtensionId = "fomrtutthjerocmw" +) + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$distPath = Join-Path $scriptDir "dist\native-host.bat" + +if (-not (Test-Path $distPath)) { + Write-Error "Build output not found: $distPath" + exit 1 +} + +$manifestPath = Join-Path $scriptDir "manifest.json" +$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json +$manifest.path = $distPath +$manifest.allowed_origins = @("chrome-extension://$ExtensionId/") +$manifest | ConvertTo-Json -Depth 5 | Set-Content $manifestPath -Encoding UTF8 +Write-Host "[OK] manifest.json updated" + +$regs = @( + "HKCU:\Software\Google\Chrome\NativeMessagingHosts\com.scriptcat.native_host", + "HKCU:\Software\Microsoft\Edge\NativeMessagingHosts\com.scriptcat.native_host" +) +foreach ($regPath in $regs) { + New-Item -Path $regPath -Force | Out-Null + Set-ItemProperty -Path $regPath -Name "(Default)" -Value $manifestPath + Write-Host "[OK] Registered: $regPath" +} +Write-Host "Done" \ No newline at end of file diff --git a/packages/native-messaging-host/launch.js b/packages/native-messaging-host/launch.js new file mode 100644 index 000000000..71df9f63b --- /dev/null +++ b/packages/native-messaging-host/launch.js @@ -0,0 +1,31 @@ +const { spawn } = require('child_process'); +const path = require('path'); + +const scriptPath = path.join(__dirname, 'dist', 'index.js'); +const node = spawn('node', [scriptPath], { + stdio: ['pipe', 'pipe', 'pipe'], + detached: true +}); + +node.stderr.on('data', (data) => { + console.error('[NativeHost]', data.toString()); +}); +node.stdout.on('data', (data) => { + console.log('[NativeHost]', data.toString()); +}); + +node.on('close', (code) => { + console.log(`NativeHost exited with code ${code}`); +}); + +// Keep stdin open by writing a dummy keepalive every 30 seconds +setInterval(() => { + if (node.stdin.writable) { + node.stdin.write('\n'); + } +}, 30000); + +// Keep this script alive +setInterval(() => {}, 1e9); +console.log(`NativeHost started with PID ${node.pid}`); +console.log('Press Ctrl+C to stop'); \ No newline at end of file diff --git a/packages/native-messaging-host/launch.mjs b/packages/native-messaging-host/launch.mjs new file mode 100644 index 000000000..d09ba2b7e --- /dev/null +++ b/packages/native-messaging-host/launch.mjs @@ -0,0 +1,32 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const scriptPath = path.join(__dirname, 'dist', 'index.js'); + +const node = spawn('node', [scriptPath], { + stdio: ['pipe', 'pipe', 'pipe'], + detached: false +}); + +node.stderr.on('data', (data) => { + process.stderr.write(`[NH] ${data}`); +}); +node.stdout.on('data', (data) => { + process.stdout.write(`[NH] ${data}`); +}); +node.on('close', (code) => { + console.log(`NativeHost exited with code ${code}`); +}); + +// Keep stdin alive with periodic writes +setInterval(() => { + if (node.stdin.writable) { + node.stdin.write('\n'); + } +}, 30000); + +// Keep this script alive +setInterval(() => {}, 1e9); +console.log(`NativeHost PID: ${node.pid}`); \ No newline at end of file diff --git a/packages/native-messaging-host/manifest.json b/packages/native-messaging-host/manifest.json new file mode 100644 index 000000000..6be3a977c --- /dev/null +++ b/packages/native-messaging-host/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "com.scriptcat.native_host", + "description": "ScriptCat Native Messaging Host", + "path": "C:\\Users\\Administrator\\ScriptCat\\packages\\native-messaging-host\\dist\\native-host.bat", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://nfeceabbbdpobdgbgpnjooobkknchemm/" + ] +} diff --git a/packages/native-messaging-host/package.json b/packages/native-messaging-host/package.json new file mode 100644 index 000000000..f9462e158 --- /dev/null +++ b/packages/native-messaging-host/package.json @@ -0,0 +1,18 @@ +{ + "name": "@scriptcat/native-messaging-host", + "version": "1.0.0", + "description": "Native Messaging Host for ScriptCat MCP/CLI integration", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js" + }, + "dependencies": { + "@types/node": "^20.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/packages/native-messaging-host/src/index.ts b/packages/native-messaging-host/src/index.ts new file mode 100644 index 000000000..5edea0779 --- /dev/null +++ b/packages/native-messaging-host/src/index.ts @@ -0,0 +1,424 @@ +#!/usr/bin/env node +/** + * ScriptCat Native Messaging Host + MCP Server (unified process) + * + * Architecture: + * AI ←HTTP/SSE→ [MCP Server :3333] ←EventEmitter→ [NativeHost → stdio → 浏览器] + * + * The NativeHost portion handles the browser's stdio protocol. + * The MCP portion serves HTTP-based MCP protocol with SSE transport. + * Both share the same process, communicating via an internal message bus. + */ + +import * as http from "node:http"; +import { EventEmitter } from "node:events"; + +// ═══════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════ + +type NativeMessageType = + | "list_scripts" + | "get_script" + | "install_script" + | "uninstall_script" + | "enable_script" + | "disable_script"; + +interface NativeRequest { + id: string; + type: NativeMessageType; + data: Record; +} + +interface NativeResponse { + id: string; + ok: boolean; + data?: unknown; + error?: string; +} + +interface ScriptSummary { + uuid: string; + name: string; + namespace: string; + version?: string; + author?: string; + type: string; + enabled: boolean; + description?: string; +} + +// ═══════════════════════════════════════════════════════════ +// Internal Message Bus +// ═══════════════════════════════════════════════════════════ + +const bus = new EventEmitter(); +bus.setMaxListeners(50); + +function sendToBrowser(request: NativeRequest): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + bus.off(responseId, handler); + reject(new Error("Timeout waiting for browser response")); + }, 30000); + + const responseId = `resp_${request.id}`; + const handler = (response: NativeResponse) => { + clearTimeout(timer); + resolve(response); + }; + + bus.once(responseId, handler); + bus.emit("to_browser", request); + }); +} + +// ═══════════════════════════════════════════════════════════ +// Native Messaging Host (stdio ←→ browser extension) +// ═══════════════════════════════════════════════════════════ + +function startNativeHost(): void { + // Async stdin reading: accumulate raw bytes, parse 4-byte LE length-prefixed JSON messages. + // Using async (EventEmitter) mode so the event loop stays free for HTTP server. + let buf = Buffer.alloc(0); + + process.stdin.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + + while (buf.length >= 4) { + const msgLen = buf.readUInt32LE(0); + if (msgLen > 1024 * 1024) { + console.error("[NativeHost] message too large:", msgLen); + buf = Buffer.alloc(0); + return; + } + const totalLen = 4 + msgLen; + if (buf.length < totalLen) break; // incomplete message, wait for more data + + try { + const msg: NativeResponse = JSON.parse(buf.subarray(4, totalLen).toString("utf-8")); + bus.emit(`resp_${msg.id}`, msg); + } catch (e) { + console.error("[NativeHost] invalid JSON from browser:", e); + } + + buf = buf.subarray(totalLen); + } + }); + + process.stdin.on("end", () => { + console.error("[NativeHost] stdin closed"); + process.exit(0); + }); + + // Send messages to browser on stdout + bus.on("to_browser", (request: NativeRequest) => { + const json = JSON.stringify(request); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(Buffer.byteLength(json, "utf-8"), 0); + process.stdout.write(lenBuf); + process.stdout.write(json); + }); + + console.error("[NativeHost] Listening on stdio for browser connection..."); +} + +// ═══════════════════════════════════════════════════════════ +// MCP Protocol Handlers +// ═══════════════════════════════════════════════════════════ + +async function handleListScripts(): Promise { + const resp = await sendToBrowser({ + id: genId(), + type: "list_scripts", + data: {}, + }); + if (!resp.ok) throw new Error(resp.error); + return (resp.data as ScriptSummary[]) || []; +} + +async function handleGetScript(uuid: string): Promise { + const resp = await sendToBrowser({ + id: genId(), + type: "get_script", + data: { uuid }, + }); + if (!resp.ok) throw new Error(resp.error); + return resp.data; +} + +async function handleInstallScript(args: { url?: string; code?: string }): Promise { + const resp = await sendToBrowser({ + id: genId(), + type: "install_script", + data: args as Record, + }); + if (!resp.ok) throw new Error(resp.error); + return resp.data; +} + +async function handleUninstallScript(uuid: string): Promise { + const resp = await sendToBrowser({ + id: genId(), + type: "uninstall_script", + data: { uuid }, + }); + if (!resp.ok) throw new Error(resp.error); + return resp.data; +} + +async function handleToggleScript(uuid: string, enable: boolean): Promise { + const resp = await sendToBrowser({ + id: genId(), + type: enable ? "enable_script" : "disable_script", + data: { uuid }, + }); + if (!resp.ok) throw new Error(resp.error); + return resp.data; +} + +// ═══════════════════════════════════════════════════════════ +// MCP over HTTP + SSE (Streamable HTTP Transport) +// ═══════════════════════════════════════════════════════════ + +const connectedClients = new Set(); +let sessionCounter = 0; + +const TOOLS = [ + { + name: "list_scripts", + description: "List all installed ScriptCat user scripts", + inputSchema: { type: "object", properties: {} as Record }, + }, + { + name: "get_script", + description: "Get a script's details and source code by UUID", + inputSchema: { + type: "object", + properties: { uuid: { type: "string", description: "Script UUID" } }, + required: ["uuid"], + }, + }, + { + name: "install_script", + description: "Install a user script from URL or JavaScript code", + inputSchema: { + type: "object", + properties: { + url: { type: "string", description: "URL of the user script" }, + code: { type: "string", description: "JavaScript code of the script" }, + }, + }, + }, + { + name: "uninstall_script", + description: "Uninstall a user script by UUID", + inputSchema: { + type: "object", + properties: { uuid: { type: "string", description: "Script UUID" } }, + required: ["uuid"], + }, + }, + { + name: "enable_script", + description: "Enable a user script by UUID", + inputSchema: { + type: "object", + properties: { uuid: { type: "string", description: "Script UUID" } }, + required: ["uuid"], + }, + }, + { + name: "disable_script", + description: "Disable a user script by UUID", + inputSchema: { + type: "object", + properties: { uuid: { type: "string", description: "Script UUID" } }, + required: ["uuid"], + }, + }, +]; + +async function executeToolCall(name: string, args: Record): Promise { + switch (name) { + case "list_scripts": { + const scripts = await handleListScripts(); + if (!scripts.length) return "No scripts installed."; + return `## Installed Scripts (${scripts.length})\n\n` + + scripts.map(s => + `- **${s.name}** (${s.namespace || "no ns"})\n UUID: \`${s.uuid}\` | v${s.version || "?"} | ${s.enabled ? "enabled" : "disabled"} | ${s.type}\n ${s.description || ""}` + ).join("\n\n"); + } + case "get_script": { + const script = await handleGetScript(args.uuid as string) as any; + if (!script) return `Script not found: ${args.uuid}`; + return `## ${script.name}\n**UUID:** \`${script.uuid}\`\n**Code (first 2000 chars):**\n\`\`\`javascript\n${(script.code || "").slice(0, 2000)}\n\`\`\``; + } + case "install_script": { + const result = await handleInstallScript(args) as any; + return `Installed: **${result.name}** (\`${result.uuid}\`)`; + } + case "uninstall_script": { + await handleUninstallScript(args.uuid as string); + return `Uninstalled: \`${args.uuid}\``; + } + case "enable_script": + case "disable_script": { + const enable = name === "enable_script"; + await handleToggleScript(args.uuid as string, enable); + return `Script \`${args.uuid}\` ${enable ? "enabled" : "disabled"}`; + } + default: + return `Unknown tool: ${name}`; + } +} + +// ═══════════════════════════════════════════════════════════ +// HTTP Server +// ═══════════════════════════════════════════════════════════ + +const PORT = parseInt(process.env.SCRIPTCAT_MCP_PORT || "3333", 10); + +function sendSSE(res: http.ServerResponse, event: string, data: unknown): void { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +const server = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url || "/", `http://localhost:${PORT}`); + + // ── SSE endpoint ── + if (req.method === "GET" && url.pathname === "/sse") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + const sessionId = `session_${++sessionCounter}`; + connectedClients.add(res); + + sendSSE(res, "endpoint", { uri: `/message?sessionId=${sessionId}` }); + + req.on("close", () => connectedClients.delete(res)); + return; + } + + // ── Message endpoint (JSON-RPC) ── + if (req.method === "POST" && url.pathname === "/message") { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", async () => { + try { + const rpc = JSON.parse(body); + + // Handle initialize + if (rpc.method === "initialize") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "scriptcat", version: "1.0.0" }, + }, + })); + return; + } + + // Handle notifications (no response) + if (rpc.method === "notifications/initialized") { + res.writeHead(202); + res.end(); + return; + } + + // Handle tools/list + if (rpc.method === "tools/list") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { tools: TOOLS }, + })); + return; + } + + // Handle tools/call + if (rpc.method === "tools/call") { + const { name, arguments: args } = rpc.params; + const text = await executeToolCall(name, args || {}); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { content: [{ type: "text", text }] }, + })); + return; + } + + // Unknown method + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: {}, + })); + } catch (e: any) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { code: -32603, message: e.message }, + })); + } + }); + return; + } + + // ── Health check ── + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", name: "scriptcat-mcp", version: "1.0.0" })); + return; + } + + res.writeHead(404); + res.end("Not Found"); +}); + +// ═══════════════════════════════════════════════════════════ +// Startup +// ═══════════════════════════════════════════════════════════ + +let idCounter = 0; +function genId(): string { + return `m_${Date.now()}_${++idCounter}`; +} + +// Start both +startNativeHost(); + +server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.error(`[MCP Server] Port ${PORT} already in use — another instance is running. MCP endpoint skipped.`); + } else { + console.error("[MCP Server] HTTP server error:", err); + } +}); + +server.listen(PORT, "127.0.0.1", () => { + console.error(`[MCP Server] HTTP+SSE listening on http://127.0.0.1:${PORT}`); + console.error("[MCP Server] SSE endpoint: GET /sse"); + console.error("[MCP Server] Message endpoint: POST /message"); +}); \ No newline at end of file diff --git a/packages/native-messaging-host/tsconfig.json b/packages/native-messaging-host/tsconfig.json new file mode 100644 index 000000000..28c3e77a2 --- /dev/null +++ b/packages/native-messaging-host/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 51bd17b04..cb8eb0748 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -2,6 +2,7 @@ import { DocumentationSite, ExtServer, ExtVersion } from "@App/app/const"; import { type Server } from "@Packages/message/server"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { ScriptService } from "./script"; +import { NativeMessageHandler } from "./native_msg"; import { ResourceService } from "./resource"; import { ValueService } from "./value"; import { RuntimeService } from "./runtime"; @@ -76,6 +77,13 @@ export default class ServiceWorkerManager { const value = new ValueService(this.api.group("value"), this.mq); const script = new ScriptService(systemConfig, this.api.group("script"), this.mq, value, resource, scriptDAO); script.init(); + + // 初始化 Native Messaging(MCP/CLI 桥接) + // Native Messaging requires the nativeMessaging permission in manifest.json + if (typeof chrome.runtime?.connectNative === "function") { + const nativeHandler = new NativeMessageHandler(script); + nativeHandler.start(); + } const runtime = new RuntimeService( systemConfig, this.api.group("runtime"), diff --git a/src/app/service/service_worker/native_msg.ts b/src/app/service/service_worker/native_msg.ts new file mode 100644 index 000000000..86415af6c --- /dev/null +++ b/src/app/service/service_worker/native_msg.ts @@ -0,0 +1,198 @@ +/** + * Native Messaging Handler for ScriptCat Service Worker + * + * Handles messages from the Native Messaging Host (CLI/MCP Server) + * and forwards them to ScriptService. + */ + +import Logger from "@App/app/logger/logger"; +import LoggerCore from "@App/app/logger/core"; +import type { ScriptService } from "./script"; +import type { Script } from "@App/app/repo/scripts"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { fetchScriptBody, prepareScriptByCode } from "@App/pkg/utils/script"; + +// ─── Message Types ─────────────────────────────────────── + +export type NativeMessageType = + | "list_scripts" + | "get_script" + | "install_script" + | "uninstall_script" + | "enable_script" + | "disable_script"; + +export interface NativeRequest { + id: string; + type: NativeMessageType; + data: Record; +} + +export interface NativeResponse { + id: string; + ok: boolean; + data?: unknown; + error?: string; +} + +export interface ScriptSummary { + uuid: string; + name: string; + namespace: string; + version?: string; + author?: string; + type: string; + status: string; + enabled: boolean; + updateUrl?: string; + description?: string; +} + +// ─── Native Messaging Handler ──────────────────────────── + +export class NativeMessageHandler { + private logger: Logger; + private port: chrome.runtime.Port | null = null; + + constructor(private scriptService: ScriptService) { + this.logger = LoggerCore.logger().with({ module: "native-messaging" }); + } + + private getScriptTypeName(type: number): string { + switch (type) { + case 1: return "normal"; + case 2: return "crontab"; + case 3: return "background"; + default: return "unknown"; + } + } + + private getScriptStatusName(status: number): string { + switch (status) { + case 1: return "enabled"; + case 2: return "disabled"; + default: return "unknown"; + } + } + + private toSummary(script: Script): ScriptSummary { + return { + uuid: script.uuid, + name: script.name, + namespace: script.namespace || "", + version: script.metadata.version?.[0], + author: script.metadata.author?.[0], + type: this.getScriptTypeName(script.type), + status: this.getScriptStatusName(script.status), + enabled: script.status === 1, + updateUrl: script.checkUpdateUrl, + description: script.metadata.description?.[0], + }; + } + + private connect(): void { + try { + const port = chrome.runtime.connectNative("com.scriptcat.native_host"); + this.logger.info("Native Messaging connecting...", { name: port.name }); + this.setupPort(port); + } catch (e) { + this.logger.error("Native Messaging connect failed", Logger.E(e)); + } + } + + private setupPort(port: chrome.runtime.Port): void { + this.port = port; + + port.onMessage.addListener((message: NativeRequest) => { + this.handleMessage(message).then( + (response) => { try { port.postMessage(response); } catch (_) {} }, + (error) => { + try { + port.postMessage({ + id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } catch (_) {} + } + ); + }); + + port.onDisconnect.addListener(() => { + const err = chrome.runtime.lastError; + this.logger.info("Native Messaging disconnected", { error: err?.message }); + this.port = null; + }); + } + + start(): void { + this.connect(); + this.logger.info("Native Messaging handler started"); + } + + private async handleMessage(request: NativeRequest): Promise { + const { id, type, data } = request; + + try { + let result: unknown; + + switch (type) { + case "list_scripts": { + const scripts = await this.scriptService.getAllScripts(); + result = scripts.map((s) => this.toSummary(s)); + break; + } + + case "get_script": { + result = await this.scriptService.getScriptAndCode(data.uuid as string); + break; + } + + case "install_script": { + const { url, code } = data as { url?: string; code?: string }; + if (!url && !code) throw new Error("Either 'url' or 'code' is required"); + + if (url) { + const installed = await this.scriptService.installByUrl(url, "user"); + result = { uuid: installed.uuid, name: installed.name, update: false }; + } else { + const uuid = (data.existing_uuid as string) || uuidv4(); + const installed = await this.scriptService.installByCode({ uuid, code: code!, upsertBy: "user" }); + result = { uuid: installed.uuid, name: installed.name, update: false }; + } + break; + } + + case "uninstall_script": { + const uuid = data.uuid as string; + if (!uuid) throw new Error("'uuid' is required"); + await this.scriptService.deleteScript(uuid); + result = { uuid, removed: true }; + break; + } + + case "enable_script": + case "disable_script": { + const uuid = data.uuid as string; + if (!uuid) throw new Error("'uuid' is required"); + const enable = type === "enable_script"; + await this.scriptService.enableScript({ uuid, enable }); + result = { uuid, enabled: enable }; + break; + } + + default: + throw new Error(`Unknown message type: ${type}`); + } + + return { id, ok: true, data: result }; + } catch (error) { + this.logger.error("Native message failed", { id, type }, Logger.E(error)); + return { + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} \ No newline at end of file diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 2e4dc4a92..421a29a2e 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -1282,6 +1282,10 @@ export class ScriptService { return scripts; } + async getScriptAndCode(uuid: string) { + return this.scriptDAO.getAndCode(uuid); + } + // 脚本排序,after为排序后的uuid列表 async sortScript({ after }: { before: string[]; after: string[] }) { const daoAll = await this.scriptDAO.all(); diff --git a/src/manifest.json b/src/manifest.json index 15077c546..1858620a8 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -42,7 +42,8 @@ "clipboardWrite", "unlimitedStorage", "declarativeNetRequest", - "debugger" + "debugger", + "nativeMessaging" ], "optional_permissions": [ "background", @@ -66,4 +67,4 @@ ] } ] -} \ No newline at end of file +} From 09e15cea438a2abb02e71b441e38cf6dc79bbaa2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:17:21 +0900 Subject: [PATCH 02/34] refactor(mcp): remove prelim native-messaging implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pr/secure-MCP prelim combined an unauthenticated loopback HTTP+SSE MCP server with direct installByUrl/installByCode/deleteScript/ enableScript calls from inbound native-messaging requests, a hardcoded Windows manifest path, and an unconditional connectNative() on every service worker init. Per the security redesign in workspace/.ref-docs/00-README.md, this is being replaced (not patched) by a stdio MCP bridge with OS-IPC transport, two-phase human-approved writes, and build/runtime gating — landing in following commits. Removes packages/native-messaging-host/{src/index.ts,manifest.json, install.ps1,launch.js,launch.mjs,PROTOCOL.md} and src/app/service/service_worker/native_msg.ts, plus the unconditional NativeMessageHandler wiring in service_worker/index.ts. The nativeMessaging permission stays in src/manifest.json (kept in source, stripped by pack.js per build profile — matches the existing debugger/ agent pattern) and packages/native-messaging-host/package.json is left in place, to be rewritten when the host package is reconstructed. --- packages/native-messaging-host/PROTOCOL.md | 608 ------------------- packages/native-messaging-host/install.ps1 | 30 - packages/native-messaging-host/launch.js | 31 - packages/native-messaging-host/launch.mjs | 32 - packages/native-messaging-host/manifest.json | 9 - packages/native-messaging-host/src/index.ts | 424 ------------- src/app/service/service_worker/index.ts | 7 - src/app/service/service_worker/native_msg.ts | 198 ------ 8 files changed, 1339 deletions(-) delete mode 100644 packages/native-messaging-host/PROTOCOL.md delete mode 100644 packages/native-messaging-host/install.ps1 delete mode 100644 packages/native-messaging-host/launch.js delete mode 100644 packages/native-messaging-host/launch.mjs delete mode 100644 packages/native-messaging-host/manifest.json delete mode 100644 packages/native-messaging-host/src/index.ts delete mode 100644 src/app/service/service_worker/native_msg.ts diff --git a/packages/native-messaging-host/PROTOCOL.md b/packages/native-messaging-host/PROTOCOL.md deleted file mode 100644 index bfdbce778..000000000 --- a/packages/native-messaging-host/PROTOCOL.md +++ /dev/null @@ -1,608 +0,0 @@ -# ScriptCat Native Messaging 协议文档 - -## 1. 通信架构概览 - -ScriptCat 的 Native Messaging 系统采用三层通信架构: - -``` -AI 客户端 ←─ HTTP/SSE ──→ MCP Server (:3333) ←─ EventEmitter ──→ NativeHost ←─ stdio ──→ 浏览器扩展 -``` - -| 层级 | 组件 | 传输方式 | 说明 | -|------|------|----------|------| -| 外部接口 | MCP Server | HTTP + SSE(端口 3333) | 对 AI 客户端暴露 JSON-RPC 2.0 接口 | -| 内部总线 | EventEmitter | 进程内事件 | MCP Server 与 NativeHost 之间的进程内通信 | -| 浏览器通道 | NativeHost ↔ 浏览器扩展 | stdio(4 字节 LE 长度前缀 + JSON) | Chrome Native Messaging 标准协议 | - -**数据流向:** - -1. AI 客户端通过 HTTP POST 发送 JSON-RPC 请求到 MCP Server -2. MCP Server 将请求转换为 `NativeRequest`,通过 EventEmitter 传递给 NativeHost -3. NativeHost 通过 stdio 将 `NativeRequest` 发送给浏览器扩展 -4. 浏览器扩展的 `NativeMessageHandler` 处理请求,调用 `ScriptService` 执行操作 -5. 浏览器扩展通过 stdio 返回 `NativeResponse` -6. NativeHost 接收响应,通过 EventEmitter 传回 MCP Server -7. MCP Server 将结果格式化为 JSON-RPC 响应返回给 AI 客户端 - ---- - -## 2. Stdio JSON 协议格式 - -NativeHost 与浏览器扩展之间使用 Chrome Native Messaging 标准的 stdio 协议: - -### 编码规则 - -每条消息由 **4 字节小端序(Little-Endian)无符号整数长度前缀** + **UTF-8 编码的 JSON 正文**组成。 - -``` -┌──────────────────┬──────────────────────────────┐ -│ 长度前缀 (4字节) │ JSON 正文 (N 字节) │ -│ UInt32LE │ UTF-8 encoded │ -└──────────────────┴──────────────────────────────┘ -``` - -### 发送消息(NativeHost → 浏览器) - -```typescript -const json = JSON.stringify(request); -const lenBuf = Buffer.alloc(4); -lenBuf.writeUInt32LE(Buffer.byteLength(json, "utf-8"), 0); -fs.writeSync(1, lenBuf); // 写入 4 字节长度 -fs.writeSync(1, json); // 写入 JSON 正文 -``` - -### 接收消息(浏览器 → NativeHost) - -```typescript -// 1. 读取 4 字节长度前缀 -const bytesRead = fs.readSync(0, BUF, 0, 4, null); -const msgLen = BUF.readUInt32LE(0); - -// 2. 读取 msgLen 字节的 JSON 正文 -const data = Buffer.alloc(msgLen); -// ... 循环读取直到 offset === msgLen - -// 3. 解析 JSON -const msg = JSON.parse(data.toString("utf-8")); -``` - -### 限制 - -| 项目 | 值 | -|------|----| -| 最大消息大小 | 1 MB(1,048,576 字节) | -| 响应超时时间 | 30,000 毫秒(30 秒) | - -超过 1 MB 的消息将被丢弃并输出错误日志。 - ---- - -## 3. 通用消息结构 - -### 请求(NativeRequest) - -```typescript -interface NativeRequest { - id: string; // 请求唯一标识符,格式: "m_{timestamp}_{counter}" - type: NativeMessageType; // 消息类型 - data: Record; // 请求参数 -} -``` - -### 响应(NativeResponse) - -```typescript -interface NativeResponse { - id: string; // 对应请求的 id - ok: boolean; // 是否成功 - data?: unknown; // 成功时的返回数据 - error?: string; // 失败时的错误信息 -} -``` - -### 消息类型(NativeMessageType) - -```typescript -type NativeMessageType = - | "list_scripts" - | "get_script" - | "install_script" - | "uninstall_script" - | "enable_script" - | "disable_script"; -``` - ---- - -## 4. 消息类型详细说明 - -### 4.1 list_scripts - -列出所有已安装的用户脚本。 - -**请求** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 请求 ID | -| type | string | 是 | 固定值 `"list_scripts"` | -| data | object | 是 | 空对象 `{}` | - -**请求示例** - -```json -{ - "id": "m_1716441600000_1", - "type": "list_scripts", - "data": {} -} -``` - -**响应** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | string | 请求 ID | -| ok | boolean | 固定 `true` | -| data | ScriptSummary[] | 脚本摘要列表 | - -**响应示例** - -```json -{ - "id": "m_1716441600000_1", - "ok": true, - "data": [ - { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "示例脚本", - "namespace": "https://example.com", - "version": "1.0.0", - "author": "developer", - "type": "normal", - "status": "enabled", - "enabled": true, - "updateUrl": "https://example.com/script.user.js", - "description": "这是一个示例脚本" - } - ] -} -``` - ---- - -### 4.2 get_script - -获取指定脚本的详细信息及源代码。 - -**请求** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 请求 ID | -| type | string | 是 | 固定值 `"get_script"` | -| data.uuid | string | 是 | 脚本的 UUID | - -**请求示例** - -```json -{ - "id": "m_1716441600000_2", - "type": "get_script", - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - } -} -``` - -**响应** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | string | 请求 ID | -| ok | boolean | 是否成功 | -| data | object | 脚本完整信息,包含 `code`(源代码)等字段 | -| error | string | 失败时的错误信息 | - -**响应示例** - -```json -{ - "id": "m_1716441600000_2", - "ok": true, - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "示例脚本", - "namespace": "https://example.com", - "code": "// ==UserScript==\n// @name 示例脚本\n// ==/UserScript==\nconsole.log('hello');", - "metadata": { - "name": ["示例脚本"], - "version": ["1.0.0"] - } - } -} -``` - -**错误示例** - -```json -{ - "id": "m_1716441600000_2", - "ok": false, - "error": "Script not found" -} -``` - ---- - -### 4.3 install_script - -安装用户脚本,支持通过 URL 或代码安装。 - -**请求** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 请求 ID | -| type | string | 是 | 固定值 `"install_script"` | -| data.url | string | 否* | 脚本的 URL 地址 | -| data.code | string | 否* | 脚本的 JavaScript 代码 | -| data.existing_uuid | string | 否 | 已有脚本的 UUID,用于代码安装时的更新标识 | - -> *`url` 和 `code` 必须提供其中之一,否则将返回错误。 - -**通过 URL 安装 — 请求示例** - -```json -{ - "id": "m_1716441600000_3", - "type": "install_script", - "data": { - "url": "https://example.com/script.user.js" - } -} -``` - -**通过代码安装 — 请求示例** - -```json -{ - "id": "m_1716441600000_4", - "type": "install_script", - "data": { - "code": "// ==UserScript==\n// @name 新脚本\n// ==/UserScript==\nconsole.log('hello');" - } -} -``` - -**响应** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | string | 请求 ID | -| ok | boolean | 是否成功 | -| data.uuid | string | 安装后脚本的 UUID | -| data.name | string | 安装后脚本的名称 | -| data.update | boolean | 是否为更新操作(当前固定 `false`) | -| error | string | 失败时的错误信息 | - -**响应示例** - -```json -{ - "id": "m_1716441600000_3", - "ok": true, - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "示例脚本", - "update": false - } -} -``` - -**错误示例** - -```json -{ - "id": "m_1716441600000_3", - "ok": false, - "error": "Either 'url' or 'code' is required" -} -``` - ---- - -### 4.4 uninstall_script - -卸载指定的用户脚本。 - -**请求** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 请求 ID | -| type | string | 是 | 固定值 `"uninstall_script"` | -| data.uuid | string | 是 | 要卸载脚本的 UUID | - -**请求示例** - -```json -{ - "id": "m_1716441600000_5", - "type": "uninstall_script", - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - } -} -``` - -**响应** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | string | 请求 ID | -| ok | boolean | 是否成功 | -| data.uuid | string | 被卸载脚本的 UUID | -| data.removed | boolean | 固定 `true`,表示已移除 | -| error | string | 失败时的错误信息 | - -**响应示例** - -```json -{ - "id": "m_1716441600000_5", - "ok": true, - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "removed": true - } -} -``` - -**错误示例** - -```json -{ - "id": "m_1716441600000_5", - "ok": false, - "error": "'uuid' is required" -} -``` - ---- - -### 4.5 enable_script - -启用指定的用户脚本。 - -**请求** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 请求 ID | -| type | string | 是 | 固定值 `"enable_script"` | -| data.uuid | string | 是 | 要启用脚本的 UUID | - -**请求示例** - -```json -{ - "id": "m_1716441600000_6", - "type": "enable_script", - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - } -} -``` - -**响应** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | string | 请求 ID | -| ok | boolean | 是否成功 | -| data.uuid | string | 脚本的 UUID | -| data.enabled | boolean | 固定 `true`,表示已启用 | -| error | string | 失败时的错误信息 | - -**响应示例** - -```json -{ - "id": "m_1716441600000_6", - "ok": true, - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "enabled": true - } -} -``` - ---- - -### 4.6 disable_script - -禁用指定的用户脚本。 - -**请求** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 请求 ID | -| type | string | 是 | 固定值 `"disable_script"` | -| data.uuid | string | 是 | 要禁用脚本的 UUID | - -**请求示例** - -```json -{ - "id": "m_1716441600000_7", - "type": "disable_script", - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - } -} -``` - -**响应** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | string | 请求 ID | -| ok | boolean | 是否成功 | -| data.uuid | string | 脚本的 UUID | -| data.enabled | boolean | 固定 `false`,表示已禁用 | -| error | string | 失败时的错误信息 | - -**响应示例** - -```json -{ - "id": "m_1716441600000_7", - "ok": true, - "data": { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "enabled": false - } -} -``` - ---- - -## 5. ScriptSummary 数据结构 - -`list_scripts` 返回的每个脚本摘要包含以下字段: - -```typescript -interface ScriptSummary { - uuid: string; // 脚本唯一标识符 - name: string; // 脚本名称 - namespace: string; // 脚本命名空间 - version?: string; // 脚本版本(来自 metadata.version[0]) - author?: string; // 脚本作者(来自 metadata.author[0]) - type: string; // 脚本类型 - status: string; // 脚本状态 - enabled: boolean; // 是否启用(status === 1 时为 true) - updateUrl?: string; // 脚本更新地址(checkUpdateUrl) - description?: string; // 脚本描述(来自 metadata.description[0]) -} -``` - -### type 字段取值 - -| 内部数值 | 字符串值 | 说明 | -|----------|----------|------| -| 1 | `"normal"` | 普通网页脚本 | -| 2 | `"crontab"` | 定时脚本 | -| 3 | `"background"` | 后台脚本 | -| 其他 | `"unknown"` | 未知类型 | - -### status 字段取值 - -| 内部数值 | 字符串值 | 说明 | -|----------|----------|------| -| 1 | `"enabled"` | 已启用 | -| 2 | `"disabled"` | 已禁用 | -| 其他 | `"unknown"` | 未知状态 | - ---- - -## 6. 常见错误码与错误信息 - -Native Messaging 协议本身不使用数字错误码,而是通过 `ok: false` + `error` 字符串来传递错误。以下是常见的错误信息: - -| 错误信息 | 触发场景 | 相关消息类型 | -|----------|----------|-------------| -| `"Either 'url' or 'code' is required"` | 安装脚本时未提供 url 和 code | install_script | -| `"'uuid' is required"` | 操作需要 uuid 但未提供 | uninstall_script, enable_script, disable_script | -| `"Unknown message type: {type}"` | 请求的 type 不在已知类型中 | 任意 | -| `"Timeout waiting for browser response"` | 浏览器在 30 秒内未响应 | 任意(NativeHost 侧) | -| `"Script not found"` | 指定 UUID 的脚本不存在 | get_script, uninstall_script, enable_script, disable_script | - -### MCP 层 JSON-RPC 错误码 - -MCP Server 在 HTTP 接口层使用标准 JSON-RPC 2.0 错误码: - -| 错误码 | 说明 | -|--------|------| -| -32603 | 内部错误(服务端异常) | - ---- - -## 7. 连接生命周期 - -### 7.1 建立连接 - -1. **NativeHost 启动**:Node.js 进程启动,监听 stdin,等待浏览器连接 -2. **浏览器连接**:浏览器扩展通过 `chrome.runtime.connectNative()` 发起连接 -3. **连接建立**:`NativeMessageHandler` 监听 `chrome.runtime.onConnectNative` 事件,获取 `Port` 对象 -4. **MCP Server 就绪**:HTTP 服务器在 `127.0.0.1:3333` 上开始监听 - -### 7.2 消息交换 - -``` -NativeHost 浏览器扩展 - │ │ - │──── NativeRequest (4字节长度 + JSON) ────────→│ - │ │ 处理请求 - │←─── NativeResponse (4字节长度 + JSON) ───────│ - │ │ - │──── NativeRequest ──────────────────────────→│ - │ │ 处理请求 - │←─── NativeResponse ─────────────────────────│ - │ │ -``` - -**关键特性:** - -- 请求-响应模式:每个请求通过 `id` 字段与响应对应 -- NativeHost 使用 `resp_{id}` 事件名匹配响应 -- 响应超时时间为 30 秒,超时后 Promise 被 reject -- 消息大小上限为 1 MB - -### 7.3 断开连接 - -1. **浏览器断开**:浏览器关闭或扩展被禁用时,`Port.onDisconnect` 事件触发 -2. **NativeHost 检测**:stdin 读取返回 0 字节时,NativeHost 退出进程 -3. **清理资源**:`NativeMessageHandler` 将 `port` 置为 `null`,MCP Server 的后续请求将超时 - -### 7.4 MCP 客户端连接(HTTP + SSE) - -1. **SSE 连接**:客户端 GET `/sse` 建立事件流连接 -2. **获取端点**:服务端推送 `endpoint` 事件,包含消息提交 URI -3. **发送请求**:客户端 POST `/message` 发送 JSON-RPC 2.0 请求 -4. **接收响应**:服务端返回 JSON-RPC 2.0 响应 -5. **断开**:客户端关闭 SSE 连接,服务端从 `connectedClients` 中移除 - -### 7.5 MCP 初始化流程 - -``` -客户端 MCP Server - │ │ - │──── POST /message ──────────────────────────→│ - │ { "method": "initialize", ... } │ - │←─── Response ───────────────────────────────│ - │ { protocolVersion, capabilities, ... } │ - │ │ - │──── POST /message ──────────────────────────→│ - │ { "method": "notifications/initialized"} │ - │←─── 202 Accepted ──────────────────────────│ - │ │ - │──── POST /message ──────────────────────────→│ - │ { "method": "tools/list" } │ - │←─── Response { tools: [...] } ──────────────│ - │ │ - │──── POST /message ──────────────────────────→│ - │ { "method": "tools/call", params: {...} }│ - │←─── Response { content: [...] } ────────────│ -``` - -**MCP Server 信息:** - -| 项目 | 值 | -|------|----| -| 协议版本 | `2024-11-05` | -| 服务器名称 | `scriptcat` | -| 服务器版本 | `1.0.0` | -| 默认端口 | 3333(可通过 `SCRIPTCAT_MCP_PORT` 环境变量配置) | -| 监听地址 | `127.0.0.1` | diff --git a/packages/native-messaging-host/install.ps1 b/packages/native-messaging-host/install.ps1 deleted file mode 100644 index 89cf2d4b9..000000000 --- a/packages/native-messaging-host/install.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -param( - [string]$ExtensionId = "fomrtutthjerocmw" -) - -$ErrorActionPreference = "Stop" -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$distPath = Join-Path $scriptDir "dist\native-host.bat" - -if (-not (Test-Path $distPath)) { - Write-Error "Build output not found: $distPath" - exit 1 -} - -$manifestPath = Join-Path $scriptDir "manifest.json" -$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json -$manifest.path = $distPath -$manifest.allowed_origins = @("chrome-extension://$ExtensionId/") -$manifest | ConvertTo-Json -Depth 5 | Set-Content $manifestPath -Encoding UTF8 -Write-Host "[OK] manifest.json updated" - -$regs = @( - "HKCU:\Software\Google\Chrome\NativeMessagingHosts\com.scriptcat.native_host", - "HKCU:\Software\Microsoft\Edge\NativeMessagingHosts\com.scriptcat.native_host" -) -foreach ($regPath in $regs) { - New-Item -Path $regPath -Force | Out-Null - Set-ItemProperty -Path $regPath -Name "(Default)" -Value $manifestPath - Write-Host "[OK] Registered: $regPath" -} -Write-Host "Done" \ No newline at end of file diff --git a/packages/native-messaging-host/launch.js b/packages/native-messaging-host/launch.js deleted file mode 100644 index 71df9f63b..000000000 --- a/packages/native-messaging-host/launch.js +++ /dev/null @@ -1,31 +0,0 @@ -const { spawn } = require('child_process'); -const path = require('path'); - -const scriptPath = path.join(__dirname, 'dist', 'index.js'); -const node = spawn('node', [scriptPath], { - stdio: ['pipe', 'pipe', 'pipe'], - detached: true -}); - -node.stderr.on('data', (data) => { - console.error('[NativeHost]', data.toString()); -}); -node.stdout.on('data', (data) => { - console.log('[NativeHost]', data.toString()); -}); - -node.on('close', (code) => { - console.log(`NativeHost exited with code ${code}`); -}); - -// Keep stdin open by writing a dummy keepalive every 30 seconds -setInterval(() => { - if (node.stdin.writable) { - node.stdin.write('\n'); - } -}, 30000); - -// Keep this script alive -setInterval(() => {}, 1e9); -console.log(`NativeHost started with PID ${node.pid}`); -console.log('Press Ctrl+C to stop'); \ No newline at end of file diff --git a/packages/native-messaging-host/launch.mjs b/packages/native-messaging-host/launch.mjs deleted file mode 100644 index d09ba2b7e..000000000 --- a/packages/native-messaging-host/launch.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const scriptPath = path.join(__dirname, 'dist', 'index.js'); - -const node = spawn('node', [scriptPath], { - stdio: ['pipe', 'pipe', 'pipe'], - detached: false -}); - -node.stderr.on('data', (data) => { - process.stderr.write(`[NH] ${data}`); -}); -node.stdout.on('data', (data) => { - process.stdout.write(`[NH] ${data}`); -}); -node.on('close', (code) => { - console.log(`NativeHost exited with code ${code}`); -}); - -// Keep stdin alive with periodic writes -setInterval(() => { - if (node.stdin.writable) { - node.stdin.write('\n'); - } -}, 30000); - -// Keep this script alive -setInterval(() => {}, 1e9); -console.log(`NativeHost PID: ${node.pid}`); \ No newline at end of file diff --git a/packages/native-messaging-host/manifest.json b/packages/native-messaging-host/manifest.json deleted file mode 100644 index 6be3a977c..000000000 --- a/packages/native-messaging-host/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "com.scriptcat.native_host", - "description": "ScriptCat Native Messaging Host", - "path": "C:\\Users\\Administrator\\ScriptCat\\packages\\native-messaging-host\\dist\\native-host.bat", - "type": "stdio", - "allowed_origins": [ - "chrome-extension://nfeceabbbdpobdgbgpnjooobkknchemm/" - ] -} diff --git a/packages/native-messaging-host/src/index.ts b/packages/native-messaging-host/src/index.ts deleted file mode 100644 index 5edea0779..000000000 --- a/packages/native-messaging-host/src/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env node -/** - * ScriptCat Native Messaging Host + MCP Server (unified process) - * - * Architecture: - * AI ←HTTP/SSE→ [MCP Server :3333] ←EventEmitter→ [NativeHost → stdio → 浏览器] - * - * The NativeHost portion handles the browser's stdio protocol. - * The MCP portion serves HTTP-based MCP protocol with SSE transport. - * Both share the same process, communicating via an internal message bus. - */ - -import * as http from "node:http"; -import { EventEmitter } from "node:events"; - -// ═══════════════════════════════════════════════════════════ -// Types -// ═══════════════════════════════════════════════════════════ - -type NativeMessageType = - | "list_scripts" - | "get_script" - | "install_script" - | "uninstall_script" - | "enable_script" - | "disable_script"; - -interface NativeRequest { - id: string; - type: NativeMessageType; - data: Record; -} - -interface NativeResponse { - id: string; - ok: boolean; - data?: unknown; - error?: string; -} - -interface ScriptSummary { - uuid: string; - name: string; - namespace: string; - version?: string; - author?: string; - type: string; - enabled: boolean; - description?: string; -} - -// ═══════════════════════════════════════════════════════════ -// Internal Message Bus -// ═══════════════════════════════════════════════════════════ - -const bus = new EventEmitter(); -bus.setMaxListeners(50); - -function sendToBrowser(request: NativeRequest): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - bus.off(responseId, handler); - reject(new Error("Timeout waiting for browser response")); - }, 30000); - - const responseId = `resp_${request.id}`; - const handler = (response: NativeResponse) => { - clearTimeout(timer); - resolve(response); - }; - - bus.once(responseId, handler); - bus.emit("to_browser", request); - }); -} - -// ═══════════════════════════════════════════════════════════ -// Native Messaging Host (stdio ←→ browser extension) -// ═══════════════════════════════════════════════════════════ - -function startNativeHost(): void { - // Async stdin reading: accumulate raw bytes, parse 4-byte LE length-prefixed JSON messages. - // Using async (EventEmitter) mode so the event loop stays free for HTTP server. - let buf = Buffer.alloc(0); - - process.stdin.on("data", (chunk: Buffer) => { - buf = Buffer.concat([buf, chunk]); - - while (buf.length >= 4) { - const msgLen = buf.readUInt32LE(0); - if (msgLen > 1024 * 1024) { - console.error("[NativeHost] message too large:", msgLen); - buf = Buffer.alloc(0); - return; - } - const totalLen = 4 + msgLen; - if (buf.length < totalLen) break; // incomplete message, wait for more data - - try { - const msg: NativeResponse = JSON.parse(buf.subarray(4, totalLen).toString("utf-8")); - bus.emit(`resp_${msg.id}`, msg); - } catch (e) { - console.error("[NativeHost] invalid JSON from browser:", e); - } - - buf = buf.subarray(totalLen); - } - }); - - process.stdin.on("end", () => { - console.error("[NativeHost] stdin closed"); - process.exit(0); - }); - - // Send messages to browser on stdout - bus.on("to_browser", (request: NativeRequest) => { - const json = JSON.stringify(request); - const lenBuf = Buffer.alloc(4); - lenBuf.writeUInt32LE(Buffer.byteLength(json, "utf-8"), 0); - process.stdout.write(lenBuf); - process.stdout.write(json); - }); - - console.error("[NativeHost] Listening on stdio for browser connection..."); -} - -// ═══════════════════════════════════════════════════════════ -// MCP Protocol Handlers -// ═══════════════════════════════════════════════════════════ - -async function handleListScripts(): Promise { - const resp = await sendToBrowser({ - id: genId(), - type: "list_scripts", - data: {}, - }); - if (!resp.ok) throw new Error(resp.error); - return (resp.data as ScriptSummary[]) || []; -} - -async function handleGetScript(uuid: string): Promise { - const resp = await sendToBrowser({ - id: genId(), - type: "get_script", - data: { uuid }, - }); - if (!resp.ok) throw new Error(resp.error); - return resp.data; -} - -async function handleInstallScript(args: { url?: string; code?: string }): Promise { - const resp = await sendToBrowser({ - id: genId(), - type: "install_script", - data: args as Record, - }); - if (!resp.ok) throw new Error(resp.error); - return resp.data; -} - -async function handleUninstallScript(uuid: string): Promise { - const resp = await sendToBrowser({ - id: genId(), - type: "uninstall_script", - data: { uuid }, - }); - if (!resp.ok) throw new Error(resp.error); - return resp.data; -} - -async function handleToggleScript(uuid: string, enable: boolean): Promise { - const resp = await sendToBrowser({ - id: genId(), - type: enable ? "enable_script" : "disable_script", - data: { uuid }, - }); - if (!resp.ok) throw new Error(resp.error); - return resp.data; -} - -// ═══════════════════════════════════════════════════════════ -// MCP over HTTP + SSE (Streamable HTTP Transport) -// ═══════════════════════════════════════════════════════════ - -const connectedClients = new Set(); -let sessionCounter = 0; - -const TOOLS = [ - { - name: "list_scripts", - description: "List all installed ScriptCat user scripts", - inputSchema: { type: "object", properties: {} as Record }, - }, - { - name: "get_script", - description: "Get a script's details and source code by UUID", - inputSchema: { - type: "object", - properties: { uuid: { type: "string", description: "Script UUID" } }, - required: ["uuid"], - }, - }, - { - name: "install_script", - description: "Install a user script from URL or JavaScript code", - inputSchema: { - type: "object", - properties: { - url: { type: "string", description: "URL of the user script" }, - code: { type: "string", description: "JavaScript code of the script" }, - }, - }, - }, - { - name: "uninstall_script", - description: "Uninstall a user script by UUID", - inputSchema: { - type: "object", - properties: { uuid: { type: "string", description: "Script UUID" } }, - required: ["uuid"], - }, - }, - { - name: "enable_script", - description: "Enable a user script by UUID", - inputSchema: { - type: "object", - properties: { uuid: { type: "string", description: "Script UUID" } }, - required: ["uuid"], - }, - }, - { - name: "disable_script", - description: "Disable a user script by UUID", - inputSchema: { - type: "object", - properties: { uuid: { type: "string", description: "Script UUID" } }, - required: ["uuid"], - }, - }, -]; - -async function executeToolCall(name: string, args: Record): Promise { - switch (name) { - case "list_scripts": { - const scripts = await handleListScripts(); - if (!scripts.length) return "No scripts installed."; - return `## Installed Scripts (${scripts.length})\n\n` + - scripts.map(s => - `- **${s.name}** (${s.namespace || "no ns"})\n UUID: \`${s.uuid}\` | v${s.version || "?"} | ${s.enabled ? "enabled" : "disabled"} | ${s.type}\n ${s.description || ""}` - ).join("\n\n"); - } - case "get_script": { - const script = await handleGetScript(args.uuid as string) as any; - if (!script) return `Script not found: ${args.uuid}`; - return `## ${script.name}\n**UUID:** \`${script.uuid}\`\n**Code (first 2000 chars):**\n\`\`\`javascript\n${(script.code || "").slice(0, 2000)}\n\`\`\``; - } - case "install_script": { - const result = await handleInstallScript(args) as any; - return `Installed: **${result.name}** (\`${result.uuid}\`)`; - } - case "uninstall_script": { - await handleUninstallScript(args.uuid as string); - return `Uninstalled: \`${args.uuid}\``; - } - case "enable_script": - case "disable_script": { - const enable = name === "enable_script"; - await handleToggleScript(args.uuid as string, enable); - return `Script \`${args.uuid}\` ${enable ? "enabled" : "disabled"}`; - } - default: - return `Unknown tool: ${name}`; - } -} - -// ═══════════════════════════════════════════════════════════ -// HTTP Server -// ═══════════════════════════════════════════════════════════ - -const PORT = parseInt(process.env.SCRIPTCAT_MCP_PORT || "3333", 10); - -function sendSSE(res: http.ServerResponse, event: string, data: unknown): void { - res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); -} - -const server = http.createServer(async (req, res) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id"); - - if (req.method === "OPTIONS") { - res.writeHead(204); - res.end(); - return; - } - - const url = new URL(req.url || "/", `http://localhost:${PORT}`); - - // ── SSE endpoint ── - if (req.method === "GET" && url.pathname === "/sse") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - const sessionId = `session_${++sessionCounter}`; - connectedClients.add(res); - - sendSSE(res, "endpoint", { uri: `/message?sessionId=${sessionId}` }); - - req.on("close", () => connectedClients.delete(res)); - return; - } - - // ── Message endpoint (JSON-RPC) ── - if (req.method === "POST" && url.pathname === "/message") { - let body = ""; - req.on("data", (chunk) => { body += chunk; }); - req.on("end", async () => { - try { - const rpc = JSON.parse(body); - - // Handle initialize - if (rpc.method === "initialize") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: rpc.id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "scriptcat", version: "1.0.0" }, - }, - })); - return; - } - - // Handle notifications (no response) - if (rpc.method === "notifications/initialized") { - res.writeHead(202); - res.end(); - return; - } - - // Handle tools/list - if (rpc.method === "tools/list") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: rpc.id, - result: { tools: TOOLS }, - })); - return; - } - - // Handle tools/call - if (rpc.method === "tools/call") { - const { name, arguments: args } = rpc.params; - const text = await executeToolCall(name, args || {}); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: rpc.id, - result: { content: [{ type: "text", text }] }, - })); - return; - } - - // Unknown method - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: rpc.id, - result: {}, - })); - } catch (e: any) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: null, - error: { code: -32603, message: e.message }, - })); - } - }); - return; - } - - // ── Health check ── - if (req.method === "GET" && url.pathname === "/health") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", name: "scriptcat-mcp", version: "1.0.0" })); - return; - } - - res.writeHead(404); - res.end("Not Found"); -}); - -// ═══════════════════════════════════════════════════════════ -// Startup -// ═══════════════════════════════════════════════════════════ - -let idCounter = 0; -function genId(): string { - return `m_${Date.now()}_${++idCounter}`; -} - -// Start both -startNativeHost(); - -server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - console.error(`[MCP Server] Port ${PORT} already in use — another instance is running. MCP endpoint skipped.`); - } else { - console.error("[MCP Server] HTTP server error:", err); - } -}); - -server.listen(PORT, "127.0.0.1", () => { - console.error(`[MCP Server] HTTP+SSE listening on http://127.0.0.1:${PORT}`); - console.error("[MCP Server] SSE endpoint: GET /sse"); - console.error("[MCP Server] Message endpoint: POST /message"); -}); \ No newline at end of file diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 98b1ed833..c43cef80e 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -2,7 +2,6 @@ import { DocumentationSite, ExtServer, ExtVersion } from "@App/app/const"; import { type Server } from "@Packages/message/server"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { ScriptService } from "./script"; -import { NativeMessageHandler } from "./native_msg"; import { ResourceService } from "./resource"; import { ValueService } from "./value"; import { RuntimeService } from "./runtime"; @@ -79,12 +78,6 @@ export default class ServiceWorkerManager { const script = new ScriptService(systemConfig, this.api.group("script"), this.mq, value, resource, scriptDAO); script.init(); - // 初始化 Native Messaging(MCP/CLI 桥接) - // Native Messaging requires the nativeMessaging permission in manifest.json - if (typeof chrome.runtime?.connectNative === "function") { - const nativeHandler = new NativeMessageHandler(script); - nativeHandler.start(); - } const runtime = new RuntimeService( systemConfig, this.api.group("runtime"), diff --git a/src/app/service/service_worker/native_msg.ts b/src/app/service/service_worker/native_msg.ts deleted file mode 100644 index 86415af6c..000000000 --- a/src/app/service/service_worker/native_msg.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Native Messaging Handler for ScriptCat Service Worker - * - * Handles messages from the Native Messaging Host (CLI/MCP Server) - * and forwards them to ScriptService. - */ - -import Logger from "@App/app/logger/logger"; -import LoggerCore from "@App/app/logger/core"; -import type { ScriptService } from "./script"; -import type { Script } from "@App/app/repo/scripts"; -import { uuidv4 } from "@App/pkg/utils/uuid"; -import { fetchScriptBody, prepareScriptByCode } from "@App/pkg/utils/script"; - -// ─── Message Types ─────────────────────────────────────── - -export type NativeMessageType = - | "list_scripts" - | "get_script" - | "install_script" - | "uninstall_script" - | "enable_script" - | "disable_script"; - -export interface NativeRequest { - id: string; - type: NativeMessageType; - data: Record; -} - -export interface NativeResponse { - id: string; - ok: boolean; - data?: unknown; - error?: string; -} - -export interface ScriptSummary { - uuid: string; - name: string; - namespace: string; - version?: string; - author?: string; - type: string; - status: string; - enabled: boolean; - updateUrl?: string; - description?: string; -} - -// ─── Native Messaging Handler ──────────────────────────── - -export class NativeMessageHandler { - private logger: Logger; - private port: chrome.runtime.Port | null = null; - - constructor(private scriptService: ScriptService) { - this.logger = LoggerCore.logger().with({ module: "native-messaging" }); - } - - private getScriptTypeName(type: number): string { - switch (type) { - case 1: return "normal"; - case 2: return "crontab"; - case 3: return "background"; - default: return "unknown"; - } - } - - private getScriptStatusName(status: number): string { - switch (status) { - case 1: return "enabled"; - case 2: return "disabled"; - default: return "unknown"; - } - } - - private toSummary(script: Script): ScriptSummary { - return { - uuid: script.uuid, - name: script.name, - namespace: script.namespace || "", - version: script.metadata.version?.[0], - author: script.metadata.author?.[0], - type: this.getScriptTypeName(script.type), - status: this.getScriptStatusName(script.status), - enabled: script.status === 1, - updateUrl: script.checkUpdateUrl, - description: script.metadata.description?.[0], - }; - } - - private connect(): void { - try { - const port = chrome.runtime.connectNative("com.scriptcat.native_host"); - this.logger.info("Native Messaging connecting...", { name: port.name }); - this.setupPort(port); - } catch (e) { - this.logger.error("Native Messaging connect failed", Logger.E(e)); - } - } - - private setupPort(port: chrome.runtime.Port): void { - this.port = port; - - port.onMessage.addListener((message: NativeRequest) => { - this.handleMessage(message).then( - (response) => { try { port.postMessage(response); } catch (_) {} }, - (error) => { - try { - port.postMessage({ - id: message.id, - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } catch (_) {} - } - ); - }); - - port.onDisconnect.addListener(() => { - const err = chrome.runtime.lastError; - this.logger.info("Native Messaging disconnected", { error: err?.message }); - this.port = null; - }); - } - - start(): void { - this.connect(); - this.logger.info("Native Messaging handler started"); - } - - private async handleMessage(request: NativeRequest): Promise { - const { id, type, data } = request; - - try { - let result: unknown; - - switch (type) { - case "list_scripts": { - const scripts = await this.scriptService.getAllScripts(); - result = scripts.map((s) => this.toSummary(s)); - break; - } - - case "get_script": { - result = await this.scriptService.getScriptAndCode(data.uuid as string); - break; - } - - case "install_script": { - const { url, code } = data as { url?: string; code?: string }; - if (!url && !code) throw new Error("Either 'url' or 'code' is required"); - - if (url) { - const installed = await this.scriptService.installByUrl(url, "user"); - result = { uuid: installed.uuid, name: installed.name, update: false }; - } else { - const uuid = (data.existing_uuid as string) || uuidv4(); - const installed = await this.scriptService.installByCode({ uuid, code: code!, upsertBy: "user" }); - result = { uuid: installed.uuid, name: installed.name, update: false }; - } - break; - } - - case "uninstall_script": { - const uuid = data.uuid as string; - if (!uuid) throw new Error("'uuid' is required"); - await this.scriptService.deleteScript(uuid); - result = { uuid, removed: true }; - break; - } - - case "enable_script": - case "disable_script": { - const uuid = data.uuid as string; - if (!uuid) throw new Error("'uuid' is required"); - const enable = type === "enable_script"; - await this.scriptService.enableScript({ uuid, enable }); - result = { uuid, enabled: enable }; - break; - } - - default: - throw new Error(`Unknown message type: ${type}`); - } - - return { id, ok: true, data: result }; - } catch (error) { - this.logger.error("Native message failed", { id, type }, Logger.E(error)); - return { - id, - ok: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } -} \ No newline at end of file From 8595ea783e023eb4b18f3c163debe799a95d9988 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:20:27 +0900 Subject: [PATCH 03/34] feat(build): MCP build gate + mcp_enabled device-local config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing EnableAgent build-gate pattern, but with reversed polarity: EnableMCP (src/app/const.ts) defaults off even on local developer builds, requiring an explicit SC_ENABLE_MCP=true. This is the first of the two gates required by the security redesign in workspace/.ref-docs/01-implementation-plan.md D3 — the second, runtime opt-in gate is mcp_enabled below. scripts/build-config.js gains resolveMcpEnabled/applyMcpManifest alongside the existing agent equivalents: resolveMcpEnabled derives the flag from build profile (developer -> on, store-* -> off) unless SC_ENABLE_MCP explicitly overrides it; applyMcpManifest strips the nativeMessaging permission when disabled. Deliberately does not special-case "store profile with explicit override" here — that combination is caught as a hard build-time assertion in scripts/pack.js (store artifacts must never contain nativeMessaging), landing in a later commit once there is MCP code for the assertion to guard. Adds the runtime opt-in flag mcp_enabled: registered in STORAGE_LOCAL_KEYS (device-local, never chrome.storage.sync, matching enable_script/vscode_url) with SystemConfig.getMcpEnabled/ setMcpEnabled (default false). --- scripts/build-config.js | 29 ++++++++++++++++ scripts/build-config.test.js | 62 ++++++++++++++++++++++++++++++++++- src/app/const.ts | 4 +++ src/pkg/config/config.test.ts | 14 ++++++++ src/pkg/config/config.ts | 8 +++++ src/pkg/config/consts.ts | 1 + 6 files changed, 117 insertions(+), 1 deletion(-) diff --git a/scripts/build-config.js b/scripts/build-config.js index 867727838..9fdf2d647 100644 --- a/scripts/build-config.js +++ b/scripts/build-config.js @@ -25,3 +25,32 @@ export function applyAgentManifest(manifest, agentEnabled) { permissions: (manifest.permissions || []).filter((permission) => permission !== "debugger"), }; } + +/** + * 打包时解析 MCP 开关:所有 profile 默认关闭;仅 developer profile 或 + * 环境变量 SC_ENABLE_MCP === "true" 显式开启。store 系 profile 若被显式 + * 开启,由 pack.js 的强断言在产物层面拦截(fail loud),本函数不做限制。 + * @param {{ profile: "store-stable" | "store-beta" | "developer", enableEnv: string | undefined }} param + * @returns {boolean} + */ +export function resolveMcpEnabled({ profile, enableEnv }) { + if (enableEnv === "true") return true; + if (enableEnv === "false") return false; + return profile === "developer"; +} + +/** + * 屏蔽 MCP 时移除 nativeMessaging 权限;启用时原样返回。 + * 与 applyAgentManifest 相同的不可变约定:屏蔽返回新对象,不修改入参。 + * @template {{ permissions?: string[] }} T + * @param {T} manifest + * @param {boolean} mcpEnabled + * @returns {T} + */ +export function applyMcpManifest(manifest, mcpEnabled) { + if (mcpEnabled) return manifest; + return { + ...manifest, + permissions: (manifest.permissions || []).filter((permission) => permission !== "nativeMessaging"), + }; +} diff --git a/scripts/build-config.test.js b/scripts/build-config.test.js index 73f995e9e..74cddb5a2 100644 --- a/scripts/build-config.test.js +++ b/scripts/build-config.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resolveAgentEnabled, applyAgentManifest } from "./build-config.js"; +import { resolveAgentEnabled, applyAgentManifest, resolveMcpEnabled, applyMcpManifest } from "./build-config.js"; describe("构建配置 - agent 开关", () => { describe("resolveAgentEnabled - 打包判断(稳定版屏蔽、beta 开启,SC_DISABLE_AGENT 覆盖)", () => { @@ -56,3 +56,63 @@ describe("构建配置 - agent 开关", () => { }); }); }); + +describe("构建配置 - MCP 开关", () => { + describe("resolveMcpEnabled - 打包判断(所有 profile 默认关闭,仅 developer profile 或 SC_ENABLE_MCP 显式开启)", () => { + it("developer profile 默认开启 MCP", () => { + expect(resolveMcpEnabled({ profile: "developer", enableEnv: undefined })).toBe(true); + }); + + it("store-stable profile 默认屏蔽 MCP", () => { + expect(resolveMcpEnabled({ profile: "store-stable", enableEnv: undefined })).toBe(false); + }); + + it("store-beta profile 默认屏蔽 MCP", () => { + expect(resolveMcpEnabled({ profile: "store-beta", enableEnv: undefined })).toBe(false); + }); + + it("SC_ENABLE_MCP='true' 显式开启,即使 profile 非 developer", () => { + expect(resolveMcpEnabled({ profile: "store-stable", enableEnv: "true" })).toBe(true); + }); + + it("SC_ENABLE_MCP='false' 显式屏蔽,覆盖 developer profile 默认开启", () => { + expect(resolveMcpEnabled({ profile: "developer", enableEnv: "false" })).toBe(false); + }); + + it("SC_ENABLE_MCP 为非 'true'/'false' 的值时回退到 profile 派生", () => { + expect(resolveMcpEnabled({ profile: "store-stable", enableEnv: "1" })).toBe(false); + expect(resolveMcpEnabled({ profile: "developer", enableEnv: "1" })).toBe(true); + }); + }); + + describe("applyMcpManifest", () => { + const makeManifest = () => ({ + permissions: ["tabs", "storage", "nativeMessaging"], + optional_permissions: ["background", "userScripts"], + }); + + it("启用 MCP 时原样返回 manifest 且保留 nativeMessaging 权限", () => { + const manifest = makeManifest(); + const result = applyMcpManifest(manifest, true); + expect(result).toBe(manifest); + expect(result.permissions).toContain("nativeMessaging"); + }); + + it("屏蔽 MCP 时移除 nativeMessaging 权限", () => { + const result = applyMcpManifest(makeManifest(), false); + expect(result.permissions).not.toContain("nativeMessaging"); + expect(result.permissions).toEqual(["tabs", "storage"]); + }); + + it("屏蔽 MCP 时不改动其它权限", () => { + const result = applyMcpManifest(makeManifest(), false); + expect(result.optional_permissions).toEqual(["background", "userScripts"]); + }); + + it("屏蔽 MCP 时不修改入参 manifest", () => { + const manifest = makeManifest(); + applyMcpManifest(manifest, false); + expect(manifest.permissions).toContain("nativeMessaging"); + }); + }); +}); diff --git a/src/app/const.ts b/src/app/const.ts index fe6d12fd0..1234dca63 100644 --- a/src/app/const.ts +++ b/src/app/const.ts @@ -4,6 +4,10 @@ export const ExtVersion = version; // agent 入口开关,构建时由 process.env.SC_DISABLE_AGENT 注入:默认开启,正式版打包时屏蔽。 export const EnableAgent = process.env.SC_DISABLE_AGENT !== "true"; + +// MCP 桥接开关,构建时由 process.env.SC_ENABLE_MCP 注入:默认关闭,仅 developer 构建显式开启。 +// 与 EnableAgent 极性相反 —— MCP 即使在本地开发构建下也需要显式选择加入。 +export const EnableMCP = process.env.SC_ENABLE_MCP === "true"; export const Discord = "https://discord.gg/JF76nHCCM7"; export const DocumentationSite = "https://docs.scriptcat.org"; diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index cfbf78abe..e8f00c214 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -67,6 +67,20 @@ describe("SystemConfig 双 storage 与懒迁移", () => { const syncData = await chrome.storage.sync.get("system_enable_script"); expect(syncData["system_enable_script"]).toBeUndefined(); }); + + it("mcp_enabled 应写入 local storage 而非 sync", async () => { + config.setMcpEnabled(true); + + const localData = await chrome.storage.local.get("system_mcp_enabled"); + expect(localData["system_mcp_enabled"]).toBe(true); + + const syncData = await chrome.storage.sync.get("system_mcp_enabled"); + expect(syncData["system_mcp_enabled"]).toBeUndefined(); + }); + + it("mcp_enabled 默认值为 false", async () => { + expect(await config.getMcpEnabled()).toBe(false); + }); }); describe("sync key 读写", () => { diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 0c89229f9..251b2155e 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -585,6 +585,14 @@ export class SystemConfig { return this._get("enable_script_incognito", true); } + setMcpEnabled(enable: boolean) { + this._set("mcp_enabled", enable); + } + + getMcpEnabled() { + return this._get("mcp_enabled", false); + } + setBlacklist(blacklist: string) { this._set("blacklist", blacklist); } diff --git a/src/pkg/config/consts.ts b/src/pkg/config/consts.ts index 4262081fc..683aae468 100644 --- a/src/pkg/config/consts.ts +++ b/src/pkg/config/consts.ts @@ -10,4 +10,5 @@ export const STORAGE_LOCAL_KEYS: Set = new Set([ "check_update", // 扩展更新通知及已读状态(各设备已读状态独立) "enable_script", // 全局脚本开关(设备独立) "enable_script_incognito", // 隐身模式开关(浏览器级别) + "mcp_enabled", // MCP 桥接开关(设备相关,绝不跨设备同步) ]); From 8c8fac31ff70f467af36013fa63eb83946edfdec Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:26:12 +0900 Subject: [PATCH 04/34] feat(mcp): shared bridge protocol types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the normative wire protocol for the MCP bridge (workspace/.ref- docs/03-protocol-spec.md §2-3, gitignored reference doc): packages/native-messaging-host/src/shared/protocol.ts is the canonical source — native-messaging envelope types, the McpBridgeRequest/ McpBridgeResponse envelope, the 6 scopes, 9 bridge actions, 12 error codes, operation kinds/statuses, and per-action input/result schemas. src/app/service/service_worker/mcp/types.ts is an independently maintained mirror, not an import: the host package is standalone (own lockfile, own CI job, not a pnpm workspace member) so it can't share a build graph with the extension. protocol.conformance.test.ts guards against the two copies drifting apart by comparing their literal unions and the action->scope mapping; verified the test actually catches drift by temporarily injecting a mismatched action into the extension copy and confirming the test failed, then reverting. WS-0 in workspace/.ref-docs/01-implementation-plan.md §3 — blocks the extension services and native host work landing in following commits. --- .../src/shared/protocol.ts | 264 ++++++++++++++++++ .../mcp/protocol.conformance.test.ts | 69 +++++ src/app/service/service_worker/mcp/types.ts | 197 +++++++++++++ 3 files changed, 530 insertions(+) create mode 100644 packages/native-messaging-host/src/shared/protocol.ts create mode 100644 src/app/service/service_worker/mcp/protocol.conformance.test.ts create mode 100644 src/app/service/service_worker/mcp/types.ts diff --git a/packages/native-messaging-host/src/shared/protocol.ts b/packages/native-messaging-host/src/shared/protocol.ts new file mode 100644 index 000000000..8ddd050a6 --- /dev/null +++ b/packages/native-messaging-host/src/shared/protocol.ts @@ -0,0 +1,264 @@ +/** + * MCP bridge protocol — single normative source for the browser<->host native-messaging + * envelope and the host<->extension bridge action vocabulary. + * + * Spec: workspace/.ref-docs/03-protocol-spec.md §2-3 (gitignored reference doc; this file plus + * packages/native-messaging-host/PROTOCOL.md are the in-repo, committed source of truth). + * + * The extension keeps an independently-typed mirror at + * src/app/service/service_worker/mcp/types.ts rather than importing this file, so the two + * packages never share a build graph; a conformance test compares the literal unions exported + * here against the extension's copy to catch drift (see mcp/protocol.conformance.test.ts). + */ + +export const PROTOCOL_VERSION = 1; + +// --------------------------------------------------------------------------------------------- +// Layer 1 — browser <-> native host (Chrome Native Messaging) envelope types +// --------------------------------------------------------------------------------------------- + +export const NATIVE_MESSAGE_TYPES = [ + "bridge.request", + "bridge.response", + "pair.request", + "pair.decision", + "client.revoke", + "client.sync", + "operations.changed", + "ping", + "pong", + "bridge.shutdown", +] as const; + +export type NativeMessageType = (typeof NATIVE_MESSAGE_TYPES)[number]; + +export interface NativeEnvelope { + v: 1; + type: NativeMessageType; + requestId: string; + payload: TPayload; +} + +// --------------------------------------------------------------------------------------------- +// Layer 1.5 — bridge actions (the security-relevant vocabulary carried inside +// bridge.request / bridge.response) +// --------------------------------------------------------------------------------------------- + +export const MCP_SCOPES = [ + "scripts:list", + "scripts:metadata:read", + "scripts:source:read", + "scripts:install:request", + "scripts:toggle:request", + "scripts:delete:request", +] as const; + +export type McpScope = (typeof MCP_SCOPES)[number]; + +export const BRIDGE_ACTIONS = [ + "scripts.list", + "scripts.metadata.get", + "scripts.source.get", + "scripts.install.prepare", + "scripts.toggle.request", + "scripts.delete.request", + "operations.get", + "operations.list", + "operations.cancel", +] as const; + +export type BridgeAction = (typeof BRIDGE_ACTIONS)[number]; + +export const BRIDGE_ERROR_CODES = [ + "INVALID_REQUEST", + "UNAUTHENTICATED", + "INSUFFICIENT_SCOPE", + "WRITE_MODE_DISABLED", + "USER_APPROVAL_REQUIRED", + "USER_REJECTED", + "OPERATION_EXPIRED", + "CONFLICT", + "NOT_FOUND", + "RATE_LIMITED", + "PAYLOAD_TOO_LARGE", + "INTERNAL_ERROR", +] as const; + +export type BridgeErrorCode = (typeof BRIDGE_ERROR_CODES)[number]; + +export const OPERATION_KINDS = ["install", "update", "enable", "disable", "delete", "source_disclosure"] as const; + +export type OperationKind = (typeof OPERATION_KINDS)[number]; + +export const OPERATION_STATUSES = ["awaiting_user", "approved", "rejected", "expired", "cancelled", "failed"] as const; + +export type OperationStatus = (typeof OPERATION_STATUSES)[number]; + +export interface McpBridgeRequest { + requestId: string; + protocolVersion: typeof PROTOCOL_VERSION; + // Host-injected from the authenticated broker session — a shim can never set this itself. + clientId: string; + action: BridgeAction; + input: TInput; +} + +export interface BridgeError { + code: BridgeErrorCode; + // Stable, user-facing message: no filesystem paths, no stack traces. + message: string; + operationId?: string; +} + +export type McpBridgeResponse = + | { requestId: string; ok: true; result: TResult } + | { requestId: string; ok: false; error: BridgeError }; + +// --------------------------------------------------------------------------------------------- +// Shared result/input shapes +// --------------------------------------------------------------------------------------------- + +export type ScriptType = "normal" | "crontab" | "background"; + +export interface ScriptSummary { + uuid: string; + name: string; + namespace: string; + version?: string; + author?: string; + description?: string; + type: ScriptType; + enabled: boolean; + updatedAt: string; // ISO-8601 + hasUpdateUrl: boolean; +} + +export interface ScriptMetadata extends ScriptSummary { + matches: string[]; + includes: string[]; + excludes: string[]; + grants: string[]; + connects: string[]; + requires: string[]; + resources: string[]; + runAt?: string; + crontab?: string; +} + +export interface ScriptSource { + uuid: string; + name: string; + version?: string; + code: string; + sha256: string; + contentTrust: "untrusted-user-script-source"; +} + +export interface PendingOperationRef { + operationId: string; + status: "awaiting_user"; + kind: OperationKind; + expiresAt: string; // ISO-8601, createdAt + 5 min +} + +export interface OperationStatusResult { + operationId: string; + kind: OperationKind; + status: OperationStatus; + resultSummary?: { uuid?: string; name?: string; enabled?: boolean }; + errorCode?: BridgeErrorCode; +} + +// --------------------------------------------------------------------------------------------- +// Per-action input/result schemas (doc 03 §3) +// --------------------------------------------------------------------------------------------- + +export type ScriptsListInput = Record; +export interface ScriptsListResult { + scripts: ScriptSummary[]; + contentTrust: "untrusted-user-script-metadata"; +} + +export interface ScriptsMetadataGetInput { + uuid: string; +} +export type ScriptsMetadataGetResult = ScriptMetadata & { contentTrust: "untrusted-user-script-metadata" }; + +export interface ScriptsSourceGetInput { + uuid: string; +} +export type ScriptsSourceGetResult = ScriptSource; + +export interface ScriptsInstallPrepareInput { + url?: string; + code?: string; +} +export type ScriptsInstallPrepareResult = PendingOperationRef; + +export interface ScriptsToggleRequestInput { + uuid: string; + enable: boolean; +} +export type ScriptsToggleRequestResult = PendingOperationRef; + +export interface ScriptsDeleteRequestInput { + uuid: string; +} +export type ScriptsDeleteRequestResult = PendingOperationRef; + +export interface OperationsGetInput { + operationId: string; +} +export type OperationsGetResult = OperationStatusResult; + +export type OperationsListInput = Record; +export interface OperationsListResult { + operations: OperationStatusResult[]; +} + +export interface OperationsCancelInput { + operationId: string; +} +export interface OperationsCancelResult { + operationId: string; + status: "cancelled"; +} + +/** Maps each bridge action to its strict input/result pair; drives exhaustive dispatch tables. */ +export interface BridgeActionSchema { + "scripts.list": { input: ScriptsListInput; result: ScriptsListResult }; + "scripts.metadata.get": { input: ScriptsMetadataGetInput; result: ScriptsMetadataGetResult }; + "scripts.source.get": { input: ScriptsSourceGetInput; result: ScriptsSourceGetResult }; + "scripts.install.prepare": { input: ScriptsInstallPrepareInput; result: ScriptsInstallPrepareResult }; + "scripts.toggle.request": { input: ScriptsToggleRequestInput; result: ScriptsToggleRequestResult }; + "scripts.delete.request": { input: ScriptsDeleteRequestInput; result: ScriptsDeleteRequestResult }; + "operations.get": { input: OperationsGetInput; result: OperationsGetResult }; + "operations.list": { input: OperationsListInput; result: OperationsListResult }; + "operations.cancel": { input: OperationsCancelInput; result: OperationsCancelResult }; +} + +// --------------------------------------------------------------------------------------------- +// Scope required per action (doc 03 §5 table) — used by both the host broker and the extension +// bridge for defense-in-depth authorization checks. +// --------------------------------------------------------------------------------------------- + +export const ACTION_REQUIRED_SCOPE: Record = { + "scripts.list": "scripts:list", + "scripts.metadata.get": "scripts:metadata:read", + "scripts.source.get": "scripts:source:read", + "scripts.install.prepare": "scripts:install:request", + "scripts.toggle.request": "scripts:toggle:request", + "scripts.delete.request": "scripts:delete:request", + // Operation-plumbing actions require ownership of the operation, not a fixed scope; any write + // scope suffices at the catalog-visibility level, host/extension re-check ownership per-call. + "operations.get": "scripts:install:request", + "operations.list": "scripts:install:request", + "operations.cancel": "scripts:install:request", +} as const; + +/** Write actions that require the session-only "allow write requests" flag to be on. */ +export const WRITE_ACTIONS: readonly BridgeAction[] = [ + "scripts.install.prepare", + "scripts.toggle.request", + "scripts.delete.request", +] as const; diff --git a/src/app/service/service_worker/mcp/protocol.conformance.test.ts b/src/app/service/service_worker/mcp/protocol.conformance.test.ts new file mode 100644 index 000000000..f26ac848c --- /dev/null +++ b/src/app/service/service_worker/mcp/protocol.conformance.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import * as hostProtocol from "@Packages/native-messaging-host/src/shared/protocol"; +import * as extTypes from "./types"; + +// The host package (packages/native-messaging-host) and the extension do not share a build +// graph — types.ts is an independently maintained mirror of protocol.ts (doc 03 §1: "all +// identifiers here are normative"). This test is the drift guard: any protocol change made on +// one side without the other fails here instead of silently desyncing at runtime. +describe("MCP 协议一致性 - 两侧独立副本必须同步", () => { + it("PROTOCOL_VERSION 一致", () => { + expect(extTypes.PROTOCOL_VERSION).toBe(hostProtocol.PROTOCOL_VERSION); + }); + + it("NATIVE_MESSAGE_TYPES 完全一致(含顺序无关性)", () => { + expect([...extTypes.NATIVE_MESSAGE_TYPES].sort()).toEqual([...hostProtocol.NATIVE_MESSAGE_TYPES].sort()); + }); + + it("MCP_SCOPES 完全一致", () => { + expect([...extTypes.MCP_SCOPES].sort()).toEqual([...hostProtocol.MCP_SCOPES].sort()); + }); + + it("BRIDGE_ACTIONS 完全一致", () => { + expect([...extTypes.BRIDGE_ACTIONS].sort()).toEqual([...hostProtocol.BRIDGE_ACTIONS].sort()); + }); + + it("BRIDGE_ERROR_CODES 完全一致", () => { + expect([...extTypes.BRIDGE_ERROR_CODES].sort()).toEqual([...hostProtocol.BRIDGE_ERROR_CODES].sort()); + }); + + it("OPERATION_KINDS 完全一致", () => { + expect([...extTypes.OPERATION_KINDS].sort()).toEqual([...hostProtocol.OPERATION_KINDS].sort()); + }); + + it("OPERATION_STATUSES 完全一致", () => { + expect([...extTypes.OPERATION_STATUSES].sort()).toEqual([...hostProtocol.OPERATION_STATUSES].sort()); + }); + + it("ACTION_REQUIRED_SCOPE 的每个 action 在两侧映射到相同的 scope", () => { + for (const action of hostProtocol.BRIDGE_ACTIONS) { + expect(extTypes.ACTION_REQUIRED_SCOPE[action]).toBe(hostProtocol.ACTION_REQUIRED_SCOPE[action]); + } + }); + + it("WRITE_ACTIONS 完全一致", () => { + expect([...extTypes.WRITE_ACTIONS].sort()).toEqual([...hostProtocol.WRITE_ACTIONS].sort()); + }); + + it("每个 write action 都要求写入类 scope(scopes 命名以 :request 结尾)", () => { + for (const action of hostProtocol.WRITE_ACTIONS) { + expect(hostProtocol.ACTION_REQUIRED_SCOPE[action]).toMatch(/:request$/); + } + }); + + it("ID 生成约定:本规范不允许顺序/可预测 ID(协议本身不生成 ID,仅作为文档化断言存在)", () => { + // Sequential IDs (e.g. "session_1") are forbidden by doc 03 §1 and doc 04 asset A3/A7. + // This is a documentation-anchoring test: real randomness is exercised in the host's + // auth/pairing tests (doc 08 §6) once that code exists; here we just assert the schema + // types model IDs as opaque strings, not numeric/sequential fields. + const sampleRequest: hostProtocol.McpBridgeRequest = { + requestId: "test-id", + protocolVersion: hostProtocol.PROTOCOL_VERSION, + clientId: "client-id", + action: "scripts.list", + input: {}, + }; + expect(typeof sampleRequest.requestId).toBe("string"); + expect(typeof sampleRequest.clientId).toBe("string"); + }); +}); diff --git a/src/app/service/service_worker/mcp/types.ts b/src/app/service/service_worker/mcp/types.ts new file mode 100644 index 000000000..312058ce7 --- /dev/null +++ b/src/app/service/service_worker/mcp/types.ts @@ -0,0 +1,197 @@ +/** + * Extension-side mirror of the MCP bridge protocol. + * + * Intentionally NOT imported from packages/native-messaging-host/src/shared/protocol.ts — the + * two packages don't share a build graph (the host package is standalone, its own lockfile, + * built/tested by its own CI job). Kept in sync via protocol.conformance.test.ts, which imports + * both modules and compares their literal unions. + * + * Spec: workspace/.ref-docs/03-protocol-spec.md §2-3 (gitignored reference doc). + */ + +export const PROTOCOL_VERSION = 1; + +// Minimum native-host package version the extension will talk to; below this the controller +// reports status "host_outdated" and refuses to dispatch bridge calls. +export const MIN_HOST_VERSION = "0.1.0"; + +// --------------------------------------------------------------------------------------------- +// Layer 1 — browser <-> native host envelope types +// --------------------------------------------------------------------------------------------- + +export const NATIVE_MESSAGE_TYPES = [ + "bridge.request", + "bridge.response", + "pair.request", + "pair.decision", + "client.revoke", + "client.sync", + "operations.changed", + "ping", + "pong", + "bridge.shutdown", +] as const; + +export type NativeMessageType = (typeof NATIVE_MESSAGE_TYPES)[number]; + +export interface NativeEnvelope { + v: 1; + type: NativeMessageType; + requestId: string; + payload: TPayload; +} + +// --------------------------------------------------------------------------------------------- +// Layer 1.5 — bridge actions +// --------------------------------------------------------------------------------------------- + +export const MCP_SCOPES = [ + "scripts:list", + "scripts:metadata:read", + "scripts:source:read", + "scripts:install:request", + "scripts:toggle:request", + "scripts:delete:request", +] as const; + +export type McpScope = (typeof MCP_SCOPES)[number]; + +export const BRIDGE_ACTIONS = [ + "scripts.list", + "scripts.metadata.get", + "scripts.source.get", + "scripts.install.prepare", + "scripts.toggle.request", + "scripts.delete.request", + "operations.get", + "operations.list", + "operations.cancel", +] as const; + +export type BridgeAction = (typeof BRIDGE_ACTIONS)[number]; + +export const BRIDGE_ERROR_CODES = [ + "INVALID_REQUEST", + "UNAUTHENTICATED", + "INSUFFICIENT_SCOPE", + "WRITE_MODE_DISABLED", + "USER_APPROVAL_REQUIRED", + "USER_REJECTED", + "OPERATION_EXPIRED", + "CONFLICT", + "NOT_FOUND", + "RATE_LIMITED", + "PAYLOAD_TOO_LARGE", + "INTERNAL_ERROR", +] as const; + +export type BridgeErrorCode = (typeof BRIDGE_ERROR_CODES)[number]; + +export const OPERATION_KINDS = ["install", "update", "enable", "disable", "delete", "source_disclosure"] as const; + +export type OperationKind = (typeof OPERATION_KINDS)[number]; + +export const OPERATION_STATUSES = ["awaiting_user", "approved", "rejected", "expired", "cancelled", "failed"] as const; + +export type OperationStatus = (typeof OPERATION_STATUSES)[number]; + +export interface McpBridgeRequest { + requestId: string; + protocolVersion: typeof PROTOCOL_VERSION; + clientId: string; + action: BridgeAction; + input: TInput; +} + +export interface BridgeError { + code: BridgeErrorCode; + message: string; + operationId?: string; +} + +export type McpBridgeResponse = + | { requestId: string; ok: true; result: TResult } + | { requestId: string; ok: false; error: BridgeError }; + +// --------------------------------------------------------------------------------------------- +// Shared result/input shapes +// --------------------------------------------------------------------------------------------- + +export type ScriptType = "normal" | "crontab" | "background"; + +export interface ScriptSummary { + uuid: string; + name: string; + namespace: string; + version?: string; + author?: string; + description?: string; + type: ScriptType; + enabled: boolean; + updatedAt: string; + hasUpdateUrl: boolean; +} + +export interface ScriptMetadata extends ScriptSummary { + matches: string[]; + includes: string[]; + excludes: string[]; + grants: string[]; + connects: string[]; + requires: string[]; + resources: string[]; + runAt?: string; + crontab?: string; +} + +export interface ScriptSource { + uuid: string; + name: string; + version?: string; + code: string; + sha256: string; + contentTrust: "untrusted-user-script-source"; +} + +export interface PendingOperationRef { + operationId: string; + status: "awaiting_user"; + kind: OperationKind; + expiresAt: string; +} + +export interface OperationStatusResult { + operationId: string; + kind: OperationKind; + status: OperationStatus; + resultSummary?: { uuid?: string; name?: string; enabled?: boolean }; + errorCode?: BridgeErrorCode; +} + +export const ACTION_REQUIRED_SCOPE: Record = { + "scripts.list": "scripts:list", + "scripts.metadata.get": "scripts:metadata:read", + "scripts.source.get": "scripts:source:read", + "scripts.install.prepare": "scripts:install:request", + "scripts.toggle.request": "scripts:toggle:request", + "scripts.delete.request": "scripts:delete:request", + "operations.get": "scripts:install:request", + "operations.list": "scripts:install:request", + "operations.cancel": "scripts:install:request", +} as const; + +export const WRITE_ACTIONS: readonly BridgeAction[] = [ + "scripts.install.prepare", + "scripts.toggle.request", + "scripts.delete.request", +] as const; + +// --------------------------------------------------------------------------------------------- +// Extension-only types (doc 02 §3, doc 04 §4, doc 04 §9) — not part of the wire protocol, but +// colocated here per the "types.ts" slice; entities move to src/app/repo/mcp.ts in the next +// commit alongside their DAOs (repo convention: entity + DAO in one file). Declared here first +// so bridge/approval code in that commit can import a single mcp/types.ts for wire + entity +// shapes without a circular dependency on the repo layer. +// --------------------------------------------------------------------------------------------- + +export type McpBridgeStatus = "disabled" | "connecting" | "connected" | "host_unreachable" | "host_outdated"; From 5f50bc1081b063740cc6362231429c3f2fbc642b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:44:49 +0900 Subject: [PATCH 05/34] feat(mcp): extension services (repos, URL policy, approval, bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the extension-side half of WS-A (workspace/.ref-docs/ 05-extension-implementation.md §2-4), TDD-first with Chinese BDD titles per repo convention. McpController + service_worker/index.ts wiring land in a follow-up commit — this one is self-contained and independently testable. - src/app/repo/mcp.ts: McpClientDAO / McpOperationDAO / McpAuditDAO (Repo pattern, entity+DAO colocated per convention). McpAuditDAO.append prunes to a 500-event ring buffer. - mcp/url_policy.ts: validateInstallUrl + fetchInstallSourceWithPolicy (doc 04 §5) — https-only, rejects embedded credentials and syntactically local/private/loopback/link-local/multicast targets, 2 MiB stream-abort cap. Documented residual limitation: the Fetch API forces redirect:"manual" responses to be opaque, so true per-hop redirect revalidation isn't achievable from an extension service worker; this validates the initial and final (post-redirect) URL instead (doc 04 §2 asset A3 residual risk). - mcp/errors.ts: McpBridgeError, the stable-code error type threaded through approval and bridge. - mcp/approval.ts: McpApprovalService owns the McpOperation lifecycle and every doc 04 §4 TOCTOU invariant — re-verifies staged/target code hashes immediately before mutation, single-shot decisions, installs always start disabled, request idempotency, lazy expiry sweep, per-client ownership on get/list/cancel. Depends on a narrow McpScriptMutator interface (install/enable/delete only), not the full ScriptService. Extends InstallSource with "mcp" and ScriptInfo with an optional `mcp` staging marker (same extension point the skillScript flow uses at script.ts:957-966). - mcp/bridge.ts: McpBridge — strict manual allow-list input validation per action (unknown fields and malformed UUIDs rejected), re-checks scope from McpClientDAO independent of whatever the host already checked (doc 04 §3 defense in depth), gates write actions on the session write flag, dispatches reads directly and writes through McpApprovalService, writes exactly one audit event per request. Script-derived strings (name/description) pass through untouched as JSON fields, never formatted into prose — verified with a test using a script named "Ignore previous instructions..." as the injection probe. - sha256OfText added to pkg/utils/crypto.ts (existing crypto-js dep). pnpm typecheck clean; full suite green (3179 tests, up from 3161). --- src/app/repo/mcp.test.ts | 124 +++++++ src/app/repo/mcp.ts | 101 ++++++ .../service_worker/mcp/approval.test.ts | 321 +++++++++++++++++ .../service/service_worker/mcp/approval.ts | 339 ++++++++++++++++++ .../service/service_worker/mcp/bridge.test.ts | 234 ++++++++++++ src/app/service/service_worker/mcp/bridge.ts | 282 +++++++++++++++ src/app/service/service_worker/mcp/errors.ts | 14 + .../service_worker/mcp/url_policy.test.ts | 152 ++++++++ .../service/service_worker/mcp/url_policy.ts | 162 +++++++++ src/app/service/service_worker/types.ts | 2 +- src/pkg/utils/crypto.ts | 4 + src/pkg/utils/scriptInstall.ts | 7 + 12 files changed, 1741 insertions(+), 1 deletion(-) create mode 100644 src/app/repo/mcp.test.ts create mode 100644 src/app/repo/mcp.ts create mode 100644 src/app/service/service_worker/mcp/approval.test.ts create mode 100644 src/app/service/service_worker/mcp/approval.ts create mode 100644 src/app/service/service_worker/mcp/bridge.test.ts create mode 100644 src/app/service/service_worker/mcp/bridge.ts create mode 100644 src/app/service/service_worker/mcp/errors.ts create mode 100644 src/app/service/service_worker/mcp/url_policy.test.ts create mode 100644 src/app/service/service_worker/mcp/url_policy.ts diff --git a/src/app/repo/mcp.test.ts b/src/app/repo/mcp.test.ts new file mode 100644 index 000000000..e0c360b18 --- /dev/null +++ b/src/app/repo/mcp.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { McpClientDAO, McpOperationDAO, McpAuditDAO, MCP_AUDIT_RING_BUFFER_SIZE } from "./mcp"; +import type { McpClient, McpOperation, McpAuditEvent } from "./mcp"; + +function makeClient(overrides: Partial = {}): McpClient { + return { + clientId: "client-1", + displayName: "Test Client", + tokenHash: "hash-1", + scopes: ["scripts:list"], + createdAt: Date.now(), + lastUsedAt: Date.now(), + revoked: false, + ...overrides, + }; +} + +function makeOperation(overrides: Partial = {}): McpOperation { + const now = Date.now(); + return { + operationId: "op-1", + clientId: "client-1", + kind: "install", + status: "awaiting_user", + createdAt: now, + expiresAt: now + 5 * 60_000, + requestedEnabledState: false, + ...overrides, + }; +} + +function makeAuditEvent(overrides: Partial = {}): McpAuditEvent { + return { + eventId: "evt-1", + timestamp: Date.now(), + clientId: "client-1", + clientName: "Test Client", + action: "scripts.list", + decision: "allowed", + correlationId: "corr-1", + ...overrides, + }; +} + +describe("McpClientDAO", () => { + let dao: McpClientDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new McpClientDAO(); + }); + + it("save / get 往返读写一条客户端记录", async () => { + const client = makeClient(); + await dao.save(client); + const result = await dao.get(client.clientId); + expect(result).toEqual(client); + }); + + it("all 返回所有客户端记录", async () => { + await dao.save(makeClient({ clientId: "a" })); + await dao.save(makeClient({ clientId: "b" })); + const all = await dao.all(); + expect(all.map((c) => c.clientId).sort()).toEqual(["a", "b"]); + }); +}); + +describe("McpOperationDAO", () => { + let dao: McpOperationDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new McpOperationDAO(); + }); + + it("save / get 往返读写一条待批操作", async () => { + const op = makeOperation(); + await dao.save(op); + const result = await dao.get(op.operationId); + expect(result).toEqual(op); + }); + + it("byClient 只返回属于该 clientId 的操作", async () => { + await dao.save(makeOperation({ operationId: "op-a", clientId: "client-a" })); + await dao.save(makeOperation({ operationId: "op-b", clientId: "client-b" })); + const forA = await dao.byClient("client-a"); + expect(forA.map((o) => o.operationId)).toEqual(["op-a"]); + }); +}); + +describe("McpAuditDAO - 环形缓冲", () => { + let dao: McpAuditDAO; + + beforeEach(() => { + chrome.storage.local.clear(); + dao = new McpAuditDAO(); + }); + + it("append 写入的事件可通过 all 读回", async () => { + await dao.append(makeAuditEvent({ eventId: "evt-a" })); + await dao.append(makeAuditEvent({ eventId: "evt-b" })); + const all = await dao.all(); + expect(all.map((e) => e.eventId).sort()).toEqual(["evt-a", "evt-b"]); + }); + + it(`append 超过 ${MCP_AUDIT_RING_BUFFER_SIZE} 条时裁剪最旧的事件`, async () => { + for (let i = 0; i < MCP_AUDIT_RING_BUFFER_SIZE + 10; i++) { + await dao.append(makeAuditEvent({ eventId: `evt-${i}`, timestamp: i })); + } + const all = await dao.all(); + expect(all.length).toBe(MCP_AUDIT_RING_BUFFER_SIZE); + // The 10 oldest (evt-0..evt-9) must have been pruned. + const ids = new Set(all.map((e) => e.eventId)); + expect(ids.has("evt-0")).toBe(false); + expect(ids.has("evt-9")).toBe(false); + expect(ids.has(`evt-${MCP_AUDIT_RING_BUFFER_SIZE + 9}`)).toBe(true); + }); + + it("clear 清空所有审计事件", async () => { + await dao.append(makeAuditEvent({ eventId: "evt-a" })); + await dao.clear(); + expect(await dao.all()).toEqual([]); + }); +}); diff --git a/src/app/repo/mcp.ts b/src/app/repo/mcp.ts new file mode 100644 index 000000000..df499ae82 --- /dev/null +++ b/src/app/repo/mcp.ts @@ -0,0 +1,101 @@ +import { Repo } from "./repo"; +import type { + McpScope, + OperationKind, + OperationStatus, + BridgeErrorCode, +} from "@App/app/service/service_worker/mcp/types"; + +// 客户端记录(doc 02 §3):主机侧持有权威 token store,扩展侧镜像用于 UI 与 scope 判定。 +export interface McpClient { + clientId: string; // 配对时生成的随机 UUID + displayName: string; // 用户可编辑,展示时需转义 + tokenHash: string; // SHA-256(token) 十六进制;token 本身从不落地扩展侧 + scopes: McpScope[]; + createdAt: number; + lastUsedAt: number; + revoked: boolean; +} + +export class McpClientDAO extends Repo { + constructor() { + super("mcpClient"); + } + + save(client: McpClient): Promise { + return this._save(client.clientId, client); + } +} + +// 待批操作(doc 04 §4):TOCTOU 绑定字段全部保留,执行器在批准瞬间重新校验。 +export interface McpOperation { + operationId: string; // 加密安全随机 UUID + clientId: string; + kind: OperationKind; + status: OperationStatus; + createdAt: number; + expiresAt: number; // createdAt + 5 分钟 + sourceUrl?: string; + contentHash?: string; // 暂存代码的 SHA-256 + stagedUuid?: string; // TempStorageDAO 的 key + targetUuid?: string; // 更新/启用/禁用/删除的目标脚本 + existingCodeHash?: string; // 请求时目标脚本当前代码的 SHA-256 + requestedEnabledState: false; // 安装操作恒为 false,仅为文档化约束保留字面量类型 + decidedAt?: number; + errorCode?: BridgeErrorCode; +} + +export class McpOperationDAO extends Repo { + constructor() { + super("mcpOperation"); + } + + save(operation: McpOperation): Promise { + return this._save(operation.operationId, operation); + } + + byClient(clientId: string): Promise { + return this.find((_key, value) => value.clientId === clientId); + } +} + +// 审计事件(doc 04 §9):环形缓冲,永不记录 token、脚本源码或 URL 中的凭据。 +export interface McpAuditEvent { + eventId: string; + timestamp: number; + clientId: string; + clientName: string; + action: string; + targetUuid?: string; + sourceHost?: string; + contentHash?: string; + decision: "allowed" | "denied" | "awaiting_user" | "approved" | "rejected" | "expired"; + result?: "success" | "failure"; + errorCode?: string; + correlationId: string; +} + +export const MCP_AUDIT_RING_BUFFER_SIZE = 500; + +export class McpAuditDAO extends Repo { + constructor() { + super("mcpAudit"); + } + + // 追加一条事件,超出环形缓冲上限时裁剪最旧的记录(按 timestamp 排序)。 + async append(event: McpAuditEvent): Promise { + await this._save(event.eventId, event); + const all = await this.all(); + if (all.length <= MCP_AUDIT_RING_BUFFER_SIZE) { + return; + } + const sorted = [...all].sort((a, b) => a.timestamp - b.timestamp); + const toPrune = sorted.slice(0, sorted.length - MCP_AUDIT_RING_BUFFER_SIZE); + await this.deletes(toPrune.map((e) => e.eventId)); + } + + async clear(): Promise { + const all = await this.all(); + await this.deletes(all.map((e) => e.eventId)); + } +} diff --git a/src/app/service/service_worker/mcp/approval.test.ts b/src/app/service/service_worker/mcp/approval.test.ts new file mode 100644 index 000000000..8460bbcc9 --- /dev/null +++ b/src/app/service/service_worker/mcp/approval.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { McpApprovalService, INLINE_CODE_MAX_BYTES, type McpScriptMutator } from "./approval"; +import { McpBridgeError } from "./errors"; +import { McpClientDAO, McpOperationDAO, type McpClient } from "@App/app/repo/mcp"; +import { + ScriptDAO, + ScriptCodeDAO, + SCRIPT_STATUS_DISABLE, + SCRIPT_STATUS_ENABLE, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; +import { TempStorageDAO } from "@App/app/repo/tempStorage"; +import { createMockOPFS } from "@App/app/repo/test-helpers"; +import { sha256OfText } from "@App/pkg/utils/crypto"; +import * as utilsModule from "@App/pkg/utils/utils"; + +function validCode(name: string) { + return `// ==UserScript== +// @name ${name} +// @namespace test-ns +// @version 1.0.0 +// ==/UserScript== +console.log("hi");`; +} + +function makeClient(overrides: Partial = {}): McpClient { + return { + clientId: "client-1", + displayName: "Test Client", + tokenHash: "hash", + scopes: ["scripts:install:request"], + createdAt: Date.now(), + lastUsedAt: Date.now(), + revoked: false, + ...overrides, + }; +} + +describe("McpApprovalService", () => { + let mutator: McpScriptMutator & { + installScript: ReturnType; + enableScript: ReturnType; + deleteScript: ReturnType; + }; + let clientDAO: McpClientDAO; + let operationDAO: McpOperationDAO; + let scriptDAO: ScriptDAO; + let scriptCodeDAO: ScriptCodeDAO; + let tempStorageDAO: TempStorageDAO; + let service: McpApprovalService; + + beforeEach(async () => { + createMockOPFS(); + chrome.storage.local.clear(); + vi.spyOn(utilsModule, "openInCurrentTab").mockResolvedValue(undefined); + vi.stubGlobal("fetch", vi.fn()); + + mutator = { + installScript: vi.fn().mockResolvedValue({ update: false, updatetime: Date.now() }), + enableScript: vi.fn().mockResolvedValue(undefined), + deleteScript: vi.fn().mockResolvedValue(undefined), + }; + clientDAO = new McpClientDAO(); + operationDAO = new McpOperationDAO(); + scriptDAO = new ScriptDAO(); + scriptCodeDAO = new ScriptCodeDAO(); + tempStorageDAO = new TempStorageDAO(); + + await clientDAO.save(makeClient()); + + service = new McpApprovalService(mutator, scriptDAO, scriptCodeDAO, clientDAO, operationDAO, tempStorageDAO); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe("prepareInstall - 暂存但不安装", () => { + it("暂存代码、计算哈希、创建 awaiting_user 操作,且不调用 installScript", async () => { + const code = validCode("Hello"); + const ref = await service.prepareInstall({ clientId: "client-1", requestingClientName: "Test Client", code }); + + expect(ref.status).toBe("awaiting_user"); + expect(ref.kind).toBe("install"); + expect(mutator.installScript).not.toHaveBeenCalled(); + + const op = await operationDAO.get(ref.operationId); + expect(op?.contentHash).toBe(sha256OfText(code)); + expect(op?.requestedEnabledState).toBe(false); + }); + + it("url 与 code 同时提供或都不提供时拒绝", async () => { + await expect( + service.prepareInstall({ clientId: "client-1", requestingClientName: "c", url: "https://a", code: "x" }) + ).rejects.toThrow(McpBridgeError); + await expect(service.prepareInstall({ clientId: "client-1", requestingClientName: "c" })).rejects.toThrow( + McpBridgeError + ); + }); + + it("内联 code 超过 512 KiB 时在暂存前拒绝", async () => { + const big = "x".repeat(INLINE_CODE_MAX_BYTES + 1); + await expect( + service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code: big }) + ).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("URL 违反策略(私网/本地)时在抓取前拒绝", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect( + service.prepareInstall({ clientId: "client-1", requestingClientName: "c", url: "https://127.0.0.1/x.user.js" }) + ).rejects.toThrow(McpBridgeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("重复请求(同 clientId + contentHash)返回同一个 operationId,不重复弹窗", async () => { + const code = validCode("Dup"); + const ref1 = await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code }); + const ref2 = await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code }); + expect(ref2.operationId).toBe(ref1.operationId); + expect(vi.mocked(utilsModule.openInCurrentTab)).toHaveBeenCalledTimes(1); + }); + }); + + describe("decide - 批准前不产生任何写入", () => { + it("拒绝(approved=false)不调用 installScript,状态变为 rejected", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("R"), + }); + const result = await service.decide(ref.operationId, false); + expect(result.status).toBe("rejected"); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("批准后调用 installScript,且新脚本默认禁用", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("E"), + }); + const result = await service.decide(ref.operationId, true); + expect(result.status).toBe("approved"); + expect(mutator.installScript).toHaveBeenCalledTimes(1); + const installedScript = mutator.installScript.mock.calls[0][0].script; + expect(installedScript.status).toBe(SCRIPT_STATUS_DISABLE); + }); + + it("批准时显式 enable=true 才会启用新脚本", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("F"), + }); + await service.decide(ref.operationId, true, { enable: true }); + const installedScript = mutator.installScript.mock.calls[0][0].script; + expect(installedScript.status).toBe(SCRIPT_STATUS_ENABLE); + }); + + it("已过期的操作不能被批准(OPERATION_EXPIRED),且不调用 installScript", async () => { + vi.useFakeTimers(); + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("G"), + }); + vi.advanceTimersByTime(6 * 60_000); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "OPERATION_EXPIRED" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("已拒绝的操作不能被重放批准", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("H"), + }); + await service.decide(ref.operationId, false); + await expect(service.decide(ref.operationId, true)).rejects.toThrow(McpBridgeError); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("暂存代码在批准前被篡改时返回 CONFLICT,不安装", async () => { + const code = validCode("Tamper"); + const ref = await service.prepareInstall({ clientId: "client-1", requestingClientName: "c", code }); + const op = await operationDAO.get(ref.operationId); + const entry = await tempStorageDAO.get(op!.stagedUuid!); + expect(entry).toBeDefined(); + // Corrupt the recorded contentHash to simulate staged-code drift without touching OPFS directly. + await operationDAO.update(ref.operationId, { contentHash: "tampered-hash" }); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + + it("客户端已被撤销时拒绝批准", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("I"), + }); + await clientDAO.save(makeClient({ revoked: true })); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "UNAUTHENTICATED" }); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + }); + + describe("requestToggle / requestDelete - 现有脚本的写请求", () => { + async function seedScript(uuid: string, code: string) { + await scriptDAO.save({ + uuid, + name: "Existing", + author: "", + namespace: "ns", + originDomain: "", + origin: "", + checkUpdate: true, + checkUpdateUrl: "", + downloadUrl: "", + config: undefined, + metadata: { name: ["Existing"], namespace: ["ns"], version: ["1.0.0"] } as any, + selfMetadata: {}, + sort: -1, + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_DISABLE, + runStatus: "complete", + createtime: Date.now(), + updatetime: Date.now(), + checktime: Date.now(), + } as any); + await scriptCodeDAO.save({ uuid, code }); + } + + it("requestToggle 记录 existingCodeHash 并创建 awaiting_user 操作", async () => { + await seedScript("script-1", "console.log(1)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-1", enable: true }); + expect(ref.status).toBe("awaiting_user"); + const op = await operationDAO.get(ref.operationId); + expect(op?.existingCodeHash).toBe(sha256OfText("console.log(1)")); + expect(mutator.enableScript).not.toHaveBeenCalled(); + }); + + it("批准 toggle 后调用 enableScript", async () => { + await seedScript("script-2", "console.log(2)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-2", enable: true }); + await service.decide(ref.operationId, true); + expect(mutator.enableScript).toHaveBeenCalledWith({ uuid: "script-2", enable: true }); + }); + + it("目标脚本代码在请求后被修改时批准返回 CONFLICT", async () => { + await seedScript("script-3", "console.log(3)"); + const ref = await service.requestToggle({ clientId: "client-1", uuid: "script-3", enable: false }); + await scriptCodeDAO.save({ uuid: "script-3", code: "console.log('changed')" }); + await expect(service.decide(ref.operationId, true)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mutator.enableScript).not.toHaveBeenCalled(); + }); + + it("对不存在的脚本请求 toggle 返回 NOT_FOUND", async () => { + await expect( + service.requestToggle({ clientId: "client-1", uuid: "missing", enable: true }) + ).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("批准 delete 后调用 deleteScript", async () => { + await seedScript("script-4", "console.log(4)"); + const ref = await service.requestDelete({ clientId: "client-1", uuid: "script-4" }); + await service.decide(ref.operationId, true); + expect(mutator.deleteScript).toHaveBeenCalledWith("script-4", "mcp"); + }); + }); + + describe("get / list / cancel - 按 clientId 隔离", () => { + it("客户端 B 不能读取客户端 A 的操作(NOT_FOUND,而非 INSUFFICIENT_SCOPE,避免泄露存在性)", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("J"), + }); + await expect(service.getOperation("client-2", ref.operationId)).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("listOperations 只返回该 client 未过期的操作", async () => { + vi.useFakeTimers(); + const ref1 = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("K1"), + }); + const ref2 = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("K2"), + }); + await service.decide(ref2.operationId, false); + const list = await service.listOperations("client-1"); + expect(list.map((o) => o.operationId).sort()).toEqual([ref1.operationId, ref2.operationId].sort()); + + // Now expire ref1 and confirm it drops out of the list. + vi.advanceTimersByTime(6 * 60_000); + const listAfterExpiry = await service.listOperations("client-1"); + expect(listAfterExpiry.map((o) => o.operationId)).not.toContain(ref1.operationId); + }); + + it("cancelOperation 仅在 awaiting_user 时可取消,关闭挂起的批准", async () => { + const ref = await service.prepareInstall({ + clientId: "client-1", + requestingClientName: "c", + code: validCode("L"), + }); + const result = await service.cancelOperation("client-1", ref.operationId); + expect(result.status).toBe("cancelled"); + await expect(service.decide(ref.operationId, true)).rejects.toThrow(McpBridgeError); + expect(mutator.installScript).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/service/service_worker/mcp/approval.ts b/src/app/service/service_worker/mcp/approval.ts new file mode 100644 index 000000000..c89f06478 --- /dev/null +++ b/src/app/service/service_worker/mcp/approval.ts @@ -0,0 +1,339 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { sha256OfText } from "@App/pkg/utils/crypto"; +import { prepareScriptByCode } from "@App/pkg/utils/script"; +import { createTempCodeEntry, getTempCode, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; +import { TempStorageDAO, TempStorageItemType } from "@App/app/repo/tempStorage"; +import { + type ScriptDAO, + type ScriptCodeDAO, + type Script, + SCRIPT_STATUS_DISABLE, + SCRIPT_STATUS_ENABLE, +} from "@App/app/repo/scripts"; +import { McpClientDAO, McpOperationDAO, type McpOperation } from "@App/app/repo/mcp"; +import type { TScriptInstallParam, TScriptInstallReturn } from "@App/app/service/service_worker/script"; +import type { InstallSource } from "@App/app/service/service_worker/types"; +import { openInCurrentTab } from "@App/pkg/utils/utils"; +import { validateInstallUrl, fetchInstallSourceWithPolicy, UrlPolicyViolation } from "./url_policy"; +import { McpBridgeError } from "./errors"; +import type { OperationStatusResult, PendingOperationRef } from "./types"; + +// 5 分钟批准有效期(doc 04 §7)。 +export const APPROVAL_TTL_MS = 5 * 60_000; +// 内联代码上限:主机→浏览器 native message 单帧硬上限 1 MiB,512 KiB 为信封开销预留余量(doc 03 §3)。 +export const INLINE_CODE_MAX_BYTES = 512 * 1024; + +// 窄接口:McpApprovalService 只需要 ScriptService 的三个变更入口,不依赖整个 ScriptService +// (AGENTS.md「依赖窄接口」)。批准前,这三个方法均不会被调用 —— doc 04 §4 的核心不变量。 +export interface McpScriptMutator { + installScript(param: TScriptInstallParam): Promise; + enableScript(param: { uuid: string; enable: boolean }): Promise; + deleteScript(uuid: string, deleteBy?: InstallSource): Promise; +} + +function toRef(op: McpOperation): PendingOperationRef { + return { + operationId: op.operationId, + status: "awaiting_user", + kind: op.kind, + expiresAt: new Date(op.expiresAt).toISOString(), + }; +} + +function toStatusResult(op: McpOperation): OperationStatusResult { + return { + operationId: op.operationId, + kind: op.kind, + status: op.status, + errorCode: op.errorCode as OperationStatusResult["errorCode"], + }; +} + +/** + * Owns the McpOperation lifecycle (doc 04 §4 TOCTOU invariants; doc 05 §4.4). Every write the + * MCP bridge exposes becomes a pending operation here; the extension mutates scripts only + * through `decide(...)`, which is driven by an explicit human action on install.html / + * mcp_confirm.html — never by an inbound MCP request directly. + */ +export class McpApprovalService { + constructor( + private readonly mutator: McpScriptMutator, + private readonly scriptDAO: Pick, + private readonly scriptCodeDAO: Pick, + private readonly clientDAO: Pick = new McpClientDAO(), + private readonly operationDAO: McpOperationDAO = new McpOperationDAO(), + private readonly tempStorageDAO: TempStorageDAO = new TempStorageDAO() + ) {} + + async prepareInstall(params: { + clientId: string; + requestingClientName: string; + url?: string; + code?: string; + }): Promise { + if (!!params.url === !!params.code) { + throw new McpBridgeError("INVALID_REQUEST", "exactly one of url or code is required"); + } + + let code: string; + let sourceUrl: string | undefined; + if (params.url) { + const initialCheck = validateInstallUrl(params.url); + if (!initialCheck.ok) { + throw new McpBridgeError("INVALID_REQUEST", `url rejected: ${initialCheck.reason}`); + } + try { + code = await fetchInstallSourceWithPolicy(params.url); + } catch (e) { + if (e instanceof UrlPolicyViolation) { + const reasonCode = e.reason === "PAYLOAD_TOO_LARGE" ? "PAYLOAD_TOO_LARGE" : "INVALID_REQUEST"; + throw new McpBridgeError(reasonCode, `url rejected: ${e.reason}`); + } + throw e; + } + sourceUrl = params.url; + } else { + code = params.code!; + if (new TextEncoder().encode(code).length > INLINE_CODE_MAX_BYTES) { + throw new McpBridgeError("PAYLOAD_TOO_LARGE", "inline code exceeds 512 KiB"); + } + } + + const contentHash = sha256OfText(code); + + // Idempotency (doc 04 §4 invariant 8): identical (clientId, contentHash) while an install is + // still awaiting_user returns the existing operation instead of stacking a second prompt. + const existingOps = await this.operationDAO.byClient(params.clientId); + const duplicate = existingOps.find( + (op) => op.kind === "install" && op.status === "awaiting_user" && op.contentHash === contentHash + ); + if (duplicate) { + return toRef(duplicate); + } + + // scripts.install.prepare carries no target uuid, so this always stages a brand-new script — + // identical to how the browser's own webRequest-triggered install flow works (a fresh uuid + // is generated, prepareScriptByCode never falls back to name/namespace matching because a + // uuid is provided). There is deliberately no "update" path reachable from this action. + const uuid = uuidv4(); + const { script } = await prepareScriptByCode(code, sourceUrl || "", uuid); + script.status = SCRIPT_STATUS_DISABLE; // doc 04 §4 invariant 6: installs always start disabled + + const operationId = uuidv4(); + const now = Date.now(); + const operation: McpOperation = { + operationId, + clientId: params.clientId, + kind: "install", + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + sourceUrl, + contentHash, + stagedUuid: uuid, + requestedEnabledState: false, + }; + await this.operationDAO.save(operation); + + const si = (await createTempCodeEntry(false, uuid, code, sourceUrl || "", "mcp", script.metadata, {})) as [ + boolean, + ScriptInfo, + Record, + ]; + si[1].mcp = { operationId, requestingClientName: params.requestingClientName, contentHash }; + await this.tempStorageDAO.save({ key: uuid, value: si, savedAt: now, type: TempStorageItemType.tempCode }); + + await openInCurrentTab(`/src/install.html?uuid=${uuid}`); + return toRef(operation); + } + + async requestToggle(params: { clientId: string; uuid: string; enable: boolean }): Promise { + return this.requestExistingScriptOperation(params.clientId, params.uuid, params.enable ? "enable" : "disable"); + } + + async requestDelete(params: { clientId: string; uuid: string }): Promise { + return this.requestExistingScriptOperation(params.clientId, params.uuid, "delete"); + } + + private async requestExistingScriptOperation( + clientId: string, + uuid: string, + kind: "enable" | "disable" | "delete" + ): Promise { + const target = await this.scriptDAO.get(uuid); + if (!target) { + throw new McpBridgeError("NOT_FOUND", "script not found"); + } + const existingCode = await this.scriptCodeDAO.get(uuid); + + const operationId = uuidv4(); + const now = Date.now(); + const operation: McpOperation = { + operationId, + clientId, + kind, + status: "awaiting_user", + createdAt: now, + expiresAt: now + APPROVAL_TTL_MS, + targetUuid: uuid, + existingCodeHash: existingCode ? sha256OfText(existingCode.code) : undefined, + requestedEnabledState: false, + }; + await this.operationDAO.save(operation); + await openInCurrentTab(`/src/mcp_confirm.html?op=${operationId}`); + return toRef(operation); + } + + /** + * Approve or reject a pending operation. `options.enable` only applies to installs: whether + * the user flipped the enable switch on install.html (doc 04 §4 invariant 6). + */ + async decide( + operationId: string, + approved: boolean, + options: { enable?: boolean } = {} + ): Promise { + const op = await this.sweepAndGet(operationId); + if (!op) { + throw new McpBridgeError("NOT_FOUND", "operation not found", operationId); + } + // Single-shot: a decided/expired operation can never re-enter awaiting_user (replay defense, + // doc 04 §4 invariant 5). + if (op.status !== "awaiting_user") { + throw new McpBridgeError("OPERATION_EXPIRED", `operation already ${op.status}`, operationId); + } + + const client = await this.clientDAO.get(op.clientId); + if (!client || client.revoked) { + await this.operationDAO.update(op.operationId, { + status: "rejected", + decidedAt: Date.now(), + errorCode: "UNAUTHENTICATED", + }); + throw new McpBridgeError("UNAUTHENTICATED", "client revoked", operationId); + } + + if (!approved) { + await this.operationDAO.update(op.operationId, { status: "rejected", decidedAt: Date.now() }); + return toStatusResult({ ...op, status: "rejected" }); + } + + try { + const resultSummary = await this.executeApproved(op, options); + await this.operationDAO.update(op.operationId, { status: "approved", decidedAt: Date.now() }); + return { operationId: op.operationId, kind: op.kind, status: "approved", resultSummary }; + } catch (e) { + const errorCode = e instanceof McpBridgeError ? e.code : "INTERNAL_ERROR"; + await this.operationDAO.update(op.operationId, { status: "failed", decidedAt: Date.now(), errorCode }); + throw e; + } + } + + private async executeApproved( + op: McpOperation, + options: { enable?: boolean } + ): Promise<{ uuid?: string; name?: string; enabled?: boolean }> { + switch (op.kind) { + case "install": + return this.executeInstall(op, options); + case "enable": + case "disable": + return this.executeToggle(op, op.kind === "enable"); + case "delete": + return this.executeDelete(op); + default: + throw new McpBridgeError("INTERNAL_ERROR", `unsupported operation kind ${op.kind}`, op.operationId); + } + } + + private async executeInstall(op: McpOperation, options: { enable?: boolean }) { + const stagedUuid = op.stagedUuid!; + const entry = await this.tempStorageDAO.get(stagedUuid); + if (!entry) { + throw new McpBridgeError("CONFLICT", "staged install missing or expired", op.operationId); + } + const stagedCode = await getTempCode(stagedUuid); + // Re-verify the staged code hash immediately before mutation (doc 04 §4 invariant 2) — this + // is the TOCTOU check: staging and approval are separated by human reaction time, during + // which the staged entry could in principle have been overwritten by a second request. + if (!stagedCode || sha256OfText(stagedCode) !== op.contentHash) { + throw new McpBridgeError("CONFLICT", "staged code changed since request", op.operationId); + } + + const { script } = await prepareScriptByCode(stagedCode, op.sourceUrl || "", stagedUuid, true); + script.status = options.enable ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; + await this.mutator.installScript({ script, code: stagedCode, upsertBy: "mcp" }); + return { uuid: script.uuid, name: script.name, enabled: script.status === SCRIPT_STATUS_ENABLE }; + } + + private async assertTargetUnchanged(op: McpOperation): Promise + ScriptCat + + + +
+ + diff --git a/src/pages/mcp_confirm/App.test.tsx b/src/pages/mcp_confirm/App.test.tsx new file mode 100644 index 000000000..5b43db23d --- /dev/null +++ b/src/pages/mcp_confirm/App.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; + +const { getOperation, decideOperation, findInfo } = vi.hoisted(() => ({ + getOperation: vi.fn(), + decideOperation: vi.fn(), + findInfo: vi.fn(), +})); +vi.mock("@App/pages/store/features/script", () => ({ + mcpClient: { getOperation, decideOperation }, + scriptClient: { findInfo }, +})); + +import { McpConfirmView } from "./App"; + +const baseOp = (over: Record = {}) => ({ + operationId: "op-1", + kind: "enable", + status: "awaiting_user", + targetUuid: "script-uuid-1", + requestingClientName: "Claude Desktop", + ...over, +}); + +beforeAll(() => initTestLanguage("zh-CN")); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "close").mockImplementation(() => {}); + decideOperation.mockResolvedValue(undefined); + findInfo.mockResolvedValue({ uuid: "script-uuid-1", name: "自动签到脚本" }); +}); +afterEach(() => { + cleanup(); +}); + +describe("MCP 操作确认页", () => { + it("加载中的挂起操作应展示脚本名称与请求方", async () => { + getOperation.mockResolvedValue(baseOp()); + render(); + expect(await screen.findByTestId("mcp-confirm-card")).toBeInTheDocument(); + expect(screen.getByText("自动签到脚本")).toBeInTheDocument(); + expect(screen.getByText(/Claude Desktop/)).toBeInTheDocument(); + }); + + it("操作不存在或已过期时展示过期提示,而非确认卡片", async () => { + getOperation.mockResolvedValue(undefined); + render(); + expect(await screen.findByTestId("mcp-confirm-expired")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-confirm-card")).not.toBeInTheDocument(); + }); + + it("状态非 awaiting_user 时视为过期(已被决定或已取消)", async () => { + getOperation.mockResolvedValue(baseOp({ status: "approved" })); + render(); + expect(await screen.findByTestId("mcp-confirm-expired")).toBeInTheDocument(); + }); + + it("enable 操作点击批准后调用 decideOperation({approved:true, enable:true}) 并关闭窗口", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: true }) + ); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("disable 操作点击批准后 enable 参数为 false", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "disable" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-approve")); + await waitFor(() => + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true, enable: false }) + ); + }); + + it("点击拒绝调用 decideOperation({approved:false}) 并关闭窗口", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + fireEvent.click(await screen.findByTestId("mcp-confirm-reject")); + await waitFor(() => expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: false })); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it("delete 操作渲染按住确认按钮而非普通批准按钮", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "delete" })); + render(); + expect(await screen.findByTestId("mcp-confirm-hold")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-confirm-approve")).not.toBeInTheDocument(); + }); + + it("按住确认按钮持续按住直至阈值才触发批准(防止误触)", async () => { + vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame", "Date"] }); + getOperation.mockResolvedValue(baseOp({ kind: "delete" })); + render(); + const holdButton = await screen.findByTestId("mcp-confirm-hold"); + + fireEvent.pointerDown(holdButton); + // 松开过早,不应触发决定 + await act(async () => { + vi.advanceTimersByTime(200); + }); + fireEvent.pointerUp(holdButton); + expect(decideOperation).not.toHaveBeenCalled(); + + // 重新按住并持续超过阈值 + fireEvent.pointerDown(holdButton); + await act(async () => { + vi.advanceTimersByTime(1600); + }); + expect(decideOperation).toHaveBeenCalledWith({ operationId: "op-1", approved: true }); + vi.useRealTimers(); + }); + + it("重复点击批准只触发一次决定(防止双重决定)", async () => { + getOperation.mockResolvedValue(baseOp({ kind: "enable" })); + render(); + const approveButton = await screen.findByTestId("mcp-confirm-approve"); + fireEvent.click(approveButton); + fireEvent.click(approveButton); + await waitFor(() => expect(decideOperation).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/pages/mcp_confirm/App.tsx b/src/pages/mcp_confirm/App.tsx new file mode 100644 index 000000000..3df7b793c --- /dev/null +++ b/src/pages/mcp_confirm/App.tsx @@ -0,0 +1,222 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TriangleAlert, CircleAlert } from "lucide-react"; +import { Button } from "@App/pages/components/ui/button"; +import { notify } from "@App/pages/components/ui/toast"; +import { mcpClient, scriptClient } from "@App/pages/store/features/script"; +import type { Script } from "@App/app/repo/scripts"; +import { cn } from "@App/pkg/utils/cn"; + +type OperationView = Awaited>; + +// enable/disable/delete only — source-disclosure consent-gating (doc 02 §4.2) is not yet +// implemented server-side (scripts.source.get in mcp/bridge.ts reads directly, no pending +// operation created for it), so there is no such operation kind for this page to render yet. +// Tracked as a follow-up; documented rather than building UI for a flow the backend can't emit. +type SupportedKind = "enable" | "disable" | "delete"; + +const HOLD_TO_CONFIRM_MS = 1500; + +function BrandMark() { + return ( +
+ ScriptCat + {"ScriptCat"} +
+ ); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const cardClass = "flex w-full max-w-[480px] flex-col gap-5 rounded-2xl border bg-card p-7 shadow-lg"; + +/** Press-and-hold button that fires onConfirm after HOLD_TO_CONFIRM_MS of continuous hold. */ +function HoldToConfirmButton({ onConfirm, label }: { onConfirm: () => void; label: string }) { + const [progress, setProgress] = useState(0); + const frameRef = useRef(0); + const startRef = useRef(0); + + const start = () => { + startRef.current = Date.now(); + const tick = () => { + const elapsed = Date.now() - startRef.current; + const pct = Math.min(1, elapsed / HOLD_TO_CONFIRM_MS); + setProgress(pct); + if (pct >= 1) { + onConfirm(); + return; + } + frameRef.current = requestAnimationFrame(tick); + }; + frameRef.current = requestAnimationFrame(tick); + }; + + const cancel = () => { + cancelAnimationFrame(frameRef.current); + setProgress(0); + }; + + useEffect(() => () => cancelAnimationFrame(frameRef.current), []); + + return ( + + ); +} + +export function McpConfirmView({ operationId }: { operationId: string }) { + const { t } = useTranslation(["mcp", "common"]); + const [op, setOp] = useState(); + const [script, setScript] = useState