From 04371e0da6e086f2e2c85a898556bed78e63eafe Mon Sep 17 00:00:00 2001 From: icaca Date: Sun, 24 May 2026 10:42:45 +0800 Subject: [PATCH] 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 +}