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 +}