diff --git a/Plugin/GodotBridge/GodotBridge.js b/Plugin/GodotBridge/GodotBridge.js new file mode 100644 index 0000000..747456a --- /dev/null +++ b/Plugin/GodotBridge/GodotBridge.js @@ -0,0 +1,382 @@ +#!/usr/bin/env node +'use strict'; + +/* + * GodotBridge - VCP 插件 + * 作为标准 MCP Client 连接 Godot MCP Native 插件, + * 通过渐进式工具发现让 VCP Agent 操作 Godot 项目。 + * + * 协议: stdio (VCP) <-> Streamable HTTP (Godot MCP) + * 依赖: 仅 Node.js 内置模块 (http/https) + */ + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +// ---------- 配置读取 ---------- +const CONFIG = { + url: process.env.GODOT_MCP_URL || 'http://127.0.0.1:9080/mcp', + token: process.env.GODOT_MCP_TOKEN || '', + timeout: parseInt(process.env.REQUEST_TIMEOUT_MS || '30000', 10), + protocolVersion: process.env.MCP_PROTOCOL_VERSION || '2025-06-18', +}; + +// ---------- 工具领域分类 ---------- +// 依据 Godot MCP Native 的命名前缀归类,供 list_domains / discover_tools 使用。 +function classifyDomain(toolName) { + const n = String(toolName || '').toLowerCase(); + if (/(^|-)runtime(-|$)|runtime-/.test(n)) return 'runtime'; + if (/node/.test(n)) return 'node'; + if (/script|symbol/.test(n)) return 'script'; + if (/scene/.test(n)) return 'scene'; + if (/debug|breakpoint|stack|profiler|debugger/.test(n)) return 'debug'; + if (/editor|inspector|screenshot|export/.test(n)) return 'editor'; + if (/project|resource|input-action|autoload|test|uid|dependency|tileset/.test(n)) return 'project'; + return 'other'; +} + +// ---------- MCP over Streamable HTTP 客户端 ---------- +let _requestId = 0; +function nextId() { return ++_requestId; } + +// 会话状态:Streamable HTTP 首次 initialize 后可能返回 Mcp-Session-Id +let _sessionId = null; + +function postJsonRpc(method, params) { + return new Promise((resolve, reject) => { + let target; + try { + target = new URL(CONFIG.url); + } catch (e) { + return reject(new Error(`无效的 GODOT_MCP_URL: ${CONFIG.url}`)); + } + + const payload = JSON.stringify({ + jsonrpc: '2.0', + id: nextId(), + method, + params: params || {}, + }); + + const headers = { + 'Content-Type': 'application/json', + // Streamable HTTP 要求客户端声明可接受 json 与 event-stream + 'Accept': 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(payload), + }; + if (CONFIG.token) headers['Authorization'] = `Bearer ${CONFIG.token}`; + if (_sessionId) headers['Mcp-Session-Id'] = _sessionId; + + const isHttps = target.protocol === 'https:'; + const lib = isHttps ? https : http; + const options = { + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + path: target.pathname + target.search, + method: 'POST', + headers, + timeout: CONFIG.timeout, + }; + + const req = lib.request(options, (res) => { + // 捕获会话 ID + const sid = res.headers['mcp-session-id']; + if (sid) _sessionId = sid; + + let raw = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { raw += chunk; }); + res.on('end', () => { + if (res.statusCode >= 400) { + return reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`)); + } + const parsed = parseMcpResponse(raw, res.headers['content-type'] || ''); + if (parsed == null) { + return reject(new Error(`无法解析 MCP 响应: ${raw.slice(0, 500)}`)); + } + if (parsed.error) { + return reject(new Error(`MCP 错误 ${parsed.error.code}: ${parsed.error.message}`)); + } + resolve(parsed.result); + }); + }); + + req.on('timeout', () => { req.destroy(new Error(`请求超时 (${CONFIG.timeout}ms)`)); }); + req.on('error', (err) => { + if (err.code === 'ECONNREFUSED') { + return reject(new Error(`无法连接 Godot MCP (${CONFIG.url})。请确认 Godot 编辑器已启动并启用了 MCP Native 插件的 HTTP 模式。`)); + } + reject(err); + }); + + req.write(payload); + req.end(); + }); +} + +// Streamable HTTP 可能返回 application/json 或 text/event-stream(SSE) +function parseMcpResponse(raw, contentType) { + const text = String(raw || '').trim(); + if (!text) return null; + + if (contentType.includes('text/event-stream') || text.startsWith('event:') || text.includes('\ndata:') || text.startsWith('data:')) { + // 逐行提取 data: 负载,取最后一个可解析为带 id 的 JSON-RPC 对象 + const dataLines = text.split(/\r?\n/).filter((l) => l.startsWith('data:')); + for (let i = dataLines.length - 1; i >= 0; i--) { + const jsonStr = dataLines[i].slice(5).trim(); + try { + const obj = JSON.parse(jsonStr); + if (obj && (obj.result !== undefined || obj.error !== undefined)) return obj; + } catch (e) { /* 跳过非 JSON 行 */ } + } + return null; + } + + try { + return JSON.parse(text); + } catch (e) { + return null; + } +} + +// initialize 握手 —— 每个进程生命周期执行一次 +let _initialized = false; +async function ensureInitialized() { + if (_initialized) return; + await postJsonRpc('initialize', { + protocolVersion: CONFIG.protocolVersion, + capabilities: {}, + clientInfo: { name: 'VCP-GodotBridge', version: '1.0.0' }, + }); + // 通知服务器初始化完成(notification 无需等待结果,容错处理) + try { + await postJsonRpc('notifications/initialized', {}); + } catch (e) { /* 部分实现不要求此通知,忽略 */ } + _initialized = true; +} + +// 拉取全部工具(支持分页 cursor) +async function fetchAllTools() { + await ensureInitialized(); + const tools = []; + let cursor; + do { + const params = cursor ? { cursor } : {}; + const result = await postJsonRpc('tools/list', params); + if (result && Array.isArray(result.tools)) tools.push(...result.tools); + cursor = result ? result.nextCursor : undefined; + } while (cursor); + return tools; +} + +// ---------- 子命令处理 ---------- +async function handleStatus() { + const tools = await fetchAllTools(); + const domains = {}; + for (const t of tools) { + const d = classifyDomain(t.name); + domains[d] = (domains[d] || 0) + 1; + } + return { + connected: true, + endpoint: CONFIG.url, + sessionId: _sessionId || null, + protocolVersion: CONFIG.protocolVersion, + totalTools: tools.length, + domains, + }; +} + +async function handleListDomains() { + const tools = await fetchAllTools(); + const domains = {}; + for (const t of tools) { + const d = classifyDomain(t.name); + domains[d] = (domains[d] || 0) + 1; + } + return { + totalTools: tools.length, + domains, + hint: '使用 discover_tools 并传入 domain 查看某领域的工具清单。', + }; +} + +async function handleDiscoverTools(args) { + const domain = (args.domain || '').trim().toLowerCase(); + if (!domain) throw new Error('discover_tools 需要参数 domain。可先用 list_domains 查看可用领域。'); + const tools = await fetchAllTools(); + const filtered = tools + .filter((t) => classifyDomain(t.name) === domain) + .map((t) => ({ + name: t.name, + description: firstLine(t.description), + })); + if (filtered.length === 0) { + return { domain, count: 0, tools: [], hint: '该领域无工具或领域名有误,请用 list_domains 核对。' }; + } + return { + domain, + count: filtered.length, + tools: filtered, + hint: '使用 get_tool_schema 传入 tool 查看某工具完整参数。', + }; +} + +async function handleGetToolSchema(args) { + const toolName = (args.tool || '').trim(); + if (!toolName) throw new Error('get_tool_schema 需要参数 tool。'); + const tools = await fetchAllTools(); + const found = tools.find((t) => t.name === toolName); + if (!found) { + const suggestions = tools + .filter((t) => t.name.includes(toolName) || toolName.includes(t.name)) + .slice(0, 5) + .map((t) => t.name); + throw new Error(`未找到工具 "${toolName}"。${suggestions.length ? '相近工具: ' + suggestions.join(', ') : '请用 discover_tools 核对名称。'}`); + } + return { + name: found.name, + description: found.description, + inputSchema: found.inputSchema || {}, + }; +} + +async function handleCallTool(args) { + const toolName = (args.tool || '').trim(); + if (!toolName) throw new Error('call_tool 需要参数 tool。'); + + let toolArgs = args.arguments; + if (typeof toolArgs === 'string') { + const s = toolArgs.trim(); + if (s === '' ) { + toolArgs = {}; + } else { + try { + toolArgs = JSON.parse(s); + } catch (e) { + throw new Error(`arguments 不是合法 JSON: ${e.message}`); + } + } + } + if (toolArgs == null) toolArgs = {}; + if (typeof toolArgs !== 'object' || Array.isArray(toolArgs)) { + throw new Error('arguments 必须是一个 JSON 对象。'); + } + + await ensureInitialized(); + const result = await postJsonRpc('tools/call', { + name: toolName, + arguments: toolArgs, + }); + + return adaptToolResult(toolName, result); +} + +// ---------- 结果适配 ---------- +function adaptToolResult(toolName, result) { + if (!result) return { tool: toolName, content: '(空响应)' }; + + const out = { tool: toolName }; + if (result.isError) out.isError = true; + + if (Array.isArray(result.content)) { + const texts = []; + const media = []; + for (const item of result.content) { + if (!item || typeof item !== 'object') continue; + if (item.type === 'text') { + texts.push(item.text); + } else if (item.type === 'image') { + // 避免把大段 base64 直接塞进文本结果,只保留元信息 + media.push({ type: 'image', mimeType: item.mimeType || 'image/png', bytes: item.data ? item.data.length : 0 }); + } else if (item.type === 'resource') { + texts.push(`[resource] ${JSON.stringify(item.resource || {}).slice(0, 400)}`); + } else { + texts.push(JSON.stringify(item).slice(0, 400)); + } + } + if (texts.length) out.text = texts.join('\n'); + if (media.length) out.media = media; + } + + if (result.structuredContent !== undefined) { + out.structured = result.structuredContent; + } + + if (out.text === undefined && out.structured === undefined && out.media === undefined) { + out.raw = result; + } + return out; +} + +function firstLine(str) { + if (!str) return ''; + const s = String(str).trim(); + const idx = s.indexOf('\n'); + return idx === -1 ? s : s.slice(0, idx); +} + +// ---------- 输入读取与分发 ---------- +function readStdin() { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (c) => { data += c; }); + process.stdin.on('end', () => resolve(data)); + // 若无 stdin(超时保护) + setTimeout(() => { if (!data) resolve(''); }, CONFIG.timeout + 5000); + }); +} + +function parseInput(raw) { + const text = String(raw || '').trim(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch (e) { + // 兼容极简 key=value 情况(一般 VCP 传 JSON,此处兜底) + return {}; + } +} + +async function main() { + const raw = await readStdin(); + const input = parseInput(raw); + const command = (input.command || 'status').trim(); + + try { + let data; + switch (command) { + case 'status': + data = await handleStatus(); + break; + case 'list_domains': + data = await handleListDomains(); + break; + case 'discover_tools': + data = await handleDiscoverTools(input); + break; + case 'get_tool_schema': + data = await handleGetToolSchema(input); + break; + case 'call_tool': + data = await handleCallTool(input); + break; + default: + throw new Error(`未知 command "${command}"。可用: status | list_domains | discover_tools | get_tool_schema | call_tool`); + } + process.stdout.write(JSON.stringify({ + status: 'success', + result: JSON.stringify(data, null, 2), + })); + } catch (err) { + process.stdout.write(JSON.stringify({ + status: 'error', + error: err && err.message ? err.message : String(err), + })); + process.exitCode = 1; + } +} + +main(); \ No newline at end of file diff --git a/Plugin/GodotBridge/GodotBridge.zip b/Plugin/GodotBridge/GodotBridge.zip new file mode 100644 index 0000000..d01b7e9 Binary files /dev/null and b/Plugin/GodotBridge/GodotBridge.zip differ diff --git a/Plugin/GodotBridge/README.md b/Plugin/GodotBridge/README.md new file mode 100644 index 0000000..aa6ab0b --- /dev/null +++ b/Plugin/GodotBridge/README.md @@ -0,0 +1,93 @@ +# GodotBridge — VCP × Godot MCP 桥接插件 + +作为标准 **MCP Client** 连接 [Godot MCP Native](https://github.com/yurineko73/Godot-MCP-Native) 插件,让 VCP Agent 通过渐进式工具发现来读写 Godot 项目的场景、脚本、节点、资源,并控制编辑器与运行时调试。 + +## 架构定位 + +``` +VCP Agent ──stdio──> GodotBridge (本插件) ──Streamable HTTP──> Godot MCP Native ──> Godot Editor / Runtime +``` + +- **本插件只做协议适配**:MCP 初始化、会话管理、工具发现、参数校验、结果规范化。 +- **不修改 Godot 侧**:Godot MCP Native 的 155 个工具原样复用。 +- **零第三方依赖**:仅使用 Node.js 内置 `http/https` 实现 MCP 客户端。 + +## 前置条件 + +1. 目标 Godot 项目已复制 `addons/godot_mcp` 并在「项目设置 → 插件」中启用。 +2. MCP 面板配置为 HTTP 模式,默认端口 `9080`。 +3. Godot 编辑器保持运行(桥接依赖其在线)。 + +## 配置 + +复制 `config.env.example` 为 `config.env`: + +| 变量 | 说明 | 默认 | +|---|---|---| +| `GODOT_MCP_URL` | Godot MCP 的 HTTP 端点 | `http://127.0.0.1:9080/mcp` | +| `GODOT_MCP_TOKEN` | 可选,Godot 启用认证时的 Bearer Token | 空 | +| `REQUEST_TIMEOUT_MS` | 单次请求超时 | `30000` | +| `MCP_PROTOCOL_VERSION` | MCP 协议版本 | `2025-06-18` | + +> Token 不要提交到版本控制。 + +## 子命令(渐进式发现) + +为节约上下文,不把 155 个工具 Schema 一次性塞给模型,而是分层查询: + +| command | 作用 | 参数 | +|---|---|---| +| `status` | 检查连接与工具总数 | — | +| `list_domains` | 列出所有工具领域及数量 | — | +| `discover_tools` | 列出某领域的工具清单 | `domain` | +| `get_tool_schema` | 查看单个工具完整参数 | `tool` | +| `call_tool` | 调用任意 Godot MCP 工具 | `tool`, `arguments` | + +领域分类:`node` / `script` / `scene` / `editor` / `debug` / `runtime` / `project` / `other`。 + +## 调用示例 + +检查连接: +``` +<<<[TOOL_REQUEST]>>> +tool_name:「始」GodotBridge「末」, +command:「始」status「末」 +<<<[END_TOOL_REQUEST]>>> +``` + +查看某领域工具: +``` +<<<[TOOL_REQUEST]>>> +tool_name:「始」GodotBridge「末」, +command:「始」discover_tools「末」, +domain:「始」scene「末」 +<<<[END_TOOL_REQUEST]>>> +``` + +调用工具(获取场景树): +``` +<<<[TOOL_REQUEST]>>> +tool_name:「始」GodotBridge「末」, +command:「始」call_tool「末」, +tool:「始」get-scene-tree「末」, +arguments:「始」{}「末」 +<<<[END_TOOL_REQUEST]>>> +``` + +## 实现要点 + +- **Streamable HTTP**:`Accept: application/json, text/event-stream`,自动解析 JSON 与 SSE 两种响应。 +- **会话保持**:捕获 `Mcp-Session-Id` 响应头并在后续请求携带。 +- **初始化握手**:进程内首次调用时执行 `initialize` + `notifications/initialized`。 +- **分页拉取**:`tools/list` 跟随 `nextCursor` 直到取完。 +- **结果适配**:文本合并、图片仅保留元信息(避免大段 base64 污染文本)、`structuredContent` 单独返回。 + +## 后续扩展(可选) + +当前为「请求—响应」型 MVP。若需 Godot **主动推送**运行时事件(崩溃、断点命中、场景切换),需: +- Godot 侧新增 `addons/godot_mcp/vcp_bridge/`(事件通道); +- 本插件新增独立事件接收器,与请求通道隔离。 + +## 作者 + +ATRI —— 我是高性能的嘛! \ No newline at end of file diff --git a/Plugin/GodotBridge/config.env.example b/Plugin/GodotBridge/config.env.example new file mode 100644 index 0000000..93309a7 --- /dev/null +++ b/Plugin/GodotBridge/config.env.example @@ -0,0 +1,11 @@ +# Godot MCP Native 的 Streamable HTTP 端点 +GODOT_MCP_URL=http://127.0.0.1:9080/mcp + +# 可选:Godot 侧启用认证时填写 Bearer Token(不要提交到版本控制) +GODOT_MCP_TOKEN= + +# 单次请求超时(毫秒) +REQUEST_TIMEOUT_MS=30000 + +# MCP 协议版本 +MCP_PROTOCOL_VERSION=2025-06-18 \ No newline at end of file diff --git a/Plugin/GodotBridge/package.json b/Plugin/GodotBridge/package.json new file mode 100644 index 0000000..8615d1e --- /dev/null +++ b/Plugin/GodotBridge/package.json @@ -0,0 +1,9 @@ +{ + "name": "godot-bridge", + "version": "1.0.0", + "description": "VCP GodotBridge - MCP client plugin for Godot MCP Native", + "main": "GodotBridge.js", + "author": "ATRI", + "license": "MIT", + "dependencies": {} +} \ No newline at end of file diff --git a/Plugin/GodotBridge/plugin-manifest.json b/Plugin/GodotBridge/plugin-manifest.json new file mode 100644 index 0000000..4f5e1ca --- /dev/null +++ b/Plugin/GodotBridge/plugin-manifest.json @@ -0,0 +1,45 @@ +{ + "manifestVersion": "1.0.0", + "name": "GodotBridge", + "version": "1.0.0", + "displayName": "Godot MCP 桥接器", + "description": "作为标准 MCP Client 连接 Godot MCP Native 插件(默认 http://127.0.0.1:9080/mcp),让 VCP Agent 通过渐进式工具发现来读写 Godot 项目的场景、脚本、节点、资源,并控制编辑器与运行时调试。", + "author": "ATRI", + "pluginType": "synchronous", + "entryPoint": { + "type": "nodejs", + "command": "node GodotBridge.js" + }, + "communication": { + "protocol": "stdio", + "timeout": 60000 + }, + "configSchema": { + "GODOT_MCP_URL": { + "type": "string", + "description": "Godot MCP Native 的 Streamable HTTP 端点,默认 http://127.0.0.1:9080/mcp。" + }, + "GODOT_MCP_TOKEN": { + "type": "string", + "description": "可选。Godot 侧启用 auth_enabled 时的 Bearer Token。" + }, + "REQUEST_TIMEOUT_MS": { + "type": "integer", + "description": "单次 MCP 请求超时(毫秒),默认 30000。" + }, + "MCP_PROTOCOL_VERSION": { + "type": "string", + "description": "MCP 协议版本号,默认 2025-06-18。" + } + }, + "capabilities": { + "systemPromptPlaceholders": [], + "invocationCommands": [ + { + "commandIdentifier": "GodotBridge", + "description": "通过 command 字段选择子命令来操作 Godot 项目。为节约上下文,请遵循渐进式发现:先 list_domains 看领域,再 discover_tools 看某领域工具,需要时 get_tool_schema 查参数,最后 call_tool 执行。\n\n【子命令】\n- status: 检查与 Godot MCP 的连接及可用工具总数。无需其它参数。\n- list_domains: 列出所有工具领域(node/script/scene/editor/debug/runtime/project/other)及各自工具数量。\n- discover_tools: 列出指定领域的工具名与简介。参数 domain (字符串, 必需)。\n- get_tool_schema: 查看单个工具的完整参数 Schema。参数 tool (字符串, 必需)。\n- call_tool: 调用任意 Godot MCP 工具。参数 tool (字符串, 必需)、arguments (JSON 字符串或对象, 可选)。\n\n【示例:查看场景树】\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」GodotBridge「末」,\ncommand:「始」call_tool「末」,\ntool:「始」get-scene-tree「末」,\narguments:「始」{}「末」\n<<<[END_TOOL_REQUEST]>>>\n\n【示例:修改节点属性】\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」GodotBridge「末」,\ncommand:「始」call_tool「末」,\ntool:「始」update-node-property「末」,\narguments:「始」{\"node_path\":\"/root/Player\",\"property\":\"speed\",\"value\":300}「末」\n<<<[END_TOOL_REQUEST]>>>" + } + ], + "responseFormatToAI": "Godot MCP 返回:\n{result}" + } +} \ No newline at end of file diff --git a/Plugin/GodotEventReceiver/GodotEventReceiver.js b/Plugin/GodotEventReceiver/GodotEventReceiver.js new file mode 100644 index 0000000..1eb9551 --- /dev/null +++ b/Plugin/GodotEventReceiver/GodotEventReceiver.js @@ -0,0 +1,169 @@ +'use strict'; + +/* + * GodotEventReceiver - VCP service 插件(二期反向通道) + * 作为 WebSocket 服务端,接收 Godot 侧 godot_vcp_bridge 主动推送的事件。 + * + * 方向: Godot (WS client) --> 本插件 (WS server) + * 与一期 GodotBridge (VCP 请求 -> Godot 响应) 方向相反、通道隔离。 + * + * 依赖: ws(VCPToolBox 已内置) + */ + +const path = require('path'); +const fs = require('fs').promises; +const WebSocket = require('ws'); + +const LOG_DIR_NAME = 'log'; +const LOG_FILE_NAME = 'godot_events.txt'; + +let wss = null; +let logFilePath = null; +let pluginConfig = {}; +let broadcastVCPInfoFunction = null; // 由 server.js 注入(同 VCPLog 范式) + +function debugLog(...args) { + if (pluginConfig && pluginConfig.DebugMode) console.log('[GodotEventReceiver]', ...args); +} + +async function ensureLogFile(basePath) { + const dir = path.join(basePath, LOG_DIR_NAME); + try { + await fs.mkdir(dir, { recursive: true }); + logFilePath = path.join(dir, LOG_FILE_NAME); + await fs.access(logFilePath).catch(async () => { + await fs.writeFile(logFilePath, `Godot event log initialized at ${new Date().toISOString()}\n`, 'utf-8'); + }); + } catch (e) { + console.error('[GodotEventReceiver] 无法创建日志目录/文件:', e.message); + } +} + +async function writeLog(line) { + if (!logFilePath) return; + try { + await fs.appendFile(logFilePath, `${new Date().toISOString()} - ${line}\n`, 'utf-8'); + } catch (e) { + console.error('[GodotEventReceiver] 写日志失败:', e.message); + } +} + +// 校验连接令牌:优先 Authorization: Bearer,其次 ?token= +function checkAuth(req) { + const token = String(pluginConfig.GODOT_EVENT_TOKEN || '').trim(); + if (!token) return true; // 未配置令牌则放行 + const auth = req.headers['authorization'] || ''; + const m = auth.match(/^Bearer\s+(.+)$/i); + if (m && m[1].trim() === token) return true; + try { + const url = new URL(req.url, 'http://localhost'); + if (url.searchParams.get('token') === token) return true; + } catch (e) { /* ignore */ } + return false; +} + +// 统一事件信封 -> VCP 前端广播 payload +function toVcpInfo(evt) { + return { + type: 'godot_event', + source: 'GodotEventReceiver', + event_type: evt.event_type || 'unknown', + project_id: evt.project_id || null, + session_id: evt.session_id || null, + timestamp: evt.timestamp || new Date().toISOString(), + payload: evt.payload !== undefined ? evt.payload : evt, + }; +} + +function handleMessage(raw, ws) { + let evt; + try { + evt = JSON.parse(raw.toString()); + } catch (e) { + debugLog('收到非 JSON 消息,忽略:', raw.toString().slice(0, 120)); + return; + } + + // 心跳 + if (evt.event_type === 'ping' || evt.type === 'ping') { + try { ws.send(JSON.stringify({ event_type: 'pong', timestamp: new Date().toISOString() })); } catch (e) { /* ignore */ } + return; + } + + writeLog(`[${evt.event_type || 'unknown'}] ${JSON.stringify(evt)}`); + debugLog('事件:', evt.event_type, '| project:', evt.project_id); + + const shouldBroadcast = pluginConfig.GODOT_EVENT_BROADCAST !== false + && String(pluginConfig.GODOT_EVENT_BROADCAST) !== 'false'; + if (shouldBroadcast && broadcastVCPInfoFunction) { + try { broadcastVCPInfoFunction(toVcpInfo(evt)); } + catch (e) { debugLog('广播失败:', e.message); } + } +} + +function startWebSocketServer() { + const port = parseInt(pluginConfig.GODOT_EVENT_PORT || '5090', 10); + + wss = new WebSocket.Server({ port }, () => { + console.log(`[GodotEventReceiver] 监听 Godot 事件于 ws://127.0.0.1:${port}`); + }); + + wss.on('connection', (ws, req) => { + if (!checkAuth(req)) { + debugLog('连接令牌校验失败,拒绝'); + try { ws.close(4001, 'unauthorized'); } catch (e) { /* ignore */ } + return; + } + const peer = req.socket.remoteAddress; + console.log(`[GodotEventReceiver] Godot 客户端已连接: ${peer}`); + writeLog(`connection opened from ${peer}`); + + ws.on('message', (data) => handleMessage(data, ws)); + ws.on('close', () => { + debugLog('Godot 客户端断开:', peer); + writeLog(`connection closed from ${peer}`); + }); + ws.on('error', (err) => debugLog('连接错误:', err.message)); + + // 握手确认 + try { ws.send(JSON.stringify({ event_type: 'welcome', server: 'VCP-GodotEventReceiver', timestamp: new Date().toISOString() })); } catch (e) { /* ignore */ } + }); + + wss.on('error', (err) => { + console.error('[GodotEventReceiver] WebSocket 服务器错误:', err.message); + }); +} + +// ---------- VCP 插件生命周期 ---------- +function initialize(config) { + pluginConfig = config || {}; + const basePath = path.join(pluginConfig.PROJECT_BASE_PATH || __dirname, 'Plugin', 'GodotEventReceiver'); + // 若未提供 PROJECT_BASE_PATH,退回到插件自身目录 + ensureLogFile(pluginConfig.PROJECT_BASE_PATH ? basePath : __dirname); + startWebSocketServer(); + console.log(`[GodotEventReceiver] 初始化完成。端口: ${pluginConfig.GODOT_EVENT_PORT || 5090},令牌校验: ${pluginConfig.GODOT_EVENT_TOKEN ? '开启' : '关闭'}`); +} + +// server.js 注入中央 WebSocketServer 广播函数(同 VCPLog) +function setBroadcastFunctions(broadcastInfoFunc) { + broadcastVCPInfoFunction = broadcastInfoFunc; + debugLog('broadcastVCPInfoFunction 已注入'); +} + +async function shutdown() { + debugLog('关闭中...'); + if (wss) { + for (const client of wss.clients) { + try { client.close(1001, 'server shutdown'); } catch (e) { /* ignore */ } + } + wss.close(); + wss = null; + } + await writeLog('GodotEventReceiver shutdown.'); +} + +module.exports = { + initialize, + shutdown, + setBroadcastFunctions, +}; \ No newline at end of file diff --git a/Plugin/GodotEventReceiver/GodotEventReceiver.zip b/Plugin/GodotEventReceiver/GodotEventReceiver.zip new file mode 100644 index 0000000..5cae6c6 Binary files /dev/null and b/Plugin/GodotEventReceiver/GodotEventReceiver.zip differ diff --git a/Plugin/GodotEventReceiver/config.env.example b/Plugin/GodotEventReceiver/config.env.example new file mode 100644 index 0000000..e9959c9 --- /dev/null +++ b/Plugin/GodotEventReceiver/config.env.example @@ -0,0 +1,11 @@ +# 接收 Godot 事件的 WebSocket 监听端口 +GODOT_EVENT_PORT=5090 + +# 可选:连接校验令牌,需与 Godot 侧 godot_vcp_bridge 的 vcp_token 一致(留空不校验,不要提交到版本控制) +GODOT_EVENT_TOKEN= + +# 是否转发到 VCP 前端广播 +GODOT_EVENT_BROADCAST=true + +# 调试模式 +DebugMode=false \ No newline at end of file diff --git a/Plugin/GodotEventReceiver/plugin-manifest.json b/Plugin/GodotEventReceiver/plugin-manifest.json new file mode 100644 index 0000000..897e32f --- /dev/null +++ b/Plugin/GodotEventReceiver/plugin-manifest.json @@ -0,0 +1,36 @@ +{ + "manifestVersion": "1.0.0", + "name": "GodotEventReceiver", + "displayName": "Godot 事件接收器", + "version": "1.0.0", + "description": "二期反向通道:作为 WebSocket 服务端接收来自 Godot 侧 godot_vcp_bridge 插件主动推送的运行时/编辑器事件(服务器启停、工具执行、错误、日志、游戏内自定义事件),并转发到 VCP 前端广播。与 GodotBridge(请求-响应)物理隔离。", + "pluginType": "service", + "entryPoint": { + "script": "GodotEventReceiver.js" + }, + "communication": { + "protocol": "direct" + }, + "configSchema": { + "GODOT_EVENT_PORT": { + "type": "integer", + "description": "接收 Godot 事件的 WebSocket 监听端口,默认 5090。" + }, + "GODOT_EVENT_TOKEN": { + "type": "string", + "description": "可选。Godot 侧连接时需在 Authorization: Bearer 或 ?token= 中携带的校验令牌。留空则不校验。" + }, + "GODOT_EVENT_BROADCAST": { + "type": "boolean", + "description": "是否将收到的 Godot 事件转发到 VCP 前端 WebSocket 广播(默认 true)。" + }, + "DebugMode": { + "type": "boolean", + "description": "调试模式,打印详细日志。" + } + }, + "capabilities": { + "systemPromptPlaceholders": [], + "invocationCommands": [] + } +} \ No newline at end of file diff --git a/plugins.json b/plugins.json index f6a64ce..da5c261 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-08-07T11:56:24.278611+00:00", + "generatedAt": "2026-08-09T16:03:53.016348+00:00", "source": { "name": "VCP 官方插件商店", "repository": "https://github.com/lioensky/VCPDistributedServer", @@ -57,6 +57,26 @@ "category": "tool", "downloadUrl": "https://raw.githubusercontent.com/lioensky/VCPDistributedServer/main/Plugin/GitOperator/GitOperator.zip" }, + { + "name": "GodotBridge", + "displayName": "Godot MCP 桥接器", + "description": "作为标准 MCP Client 连接 Godot MCP Native 插件(默认 http://127.0.0.1:9080/mcp),让 VCP Agent 通过渐进式工具发现来读写 Godot 项目的场景、脚本、节点、资源,并控制编辑器与运行时调试。", + "version": "1.0.0", + "author": "ATRI", + "icon": "extension", + "category": "tool", + "downloadUrl": "https://raw.githubusercontent.com/lioensky/VCPDistributedServer/main/Plugin/GodotBridge/GodotBridge.zip" + }, + { + "name": "GodotEventReceiver", + "displayName": "Godot 事件接收器", + "description": "二期反向通道:作为 WebSocket 服务端接收来自 Godot 侧 godot_vcp_bridge 插件主动推送的运行时/编辑器事件(服务器启停、工具执行、错误、日志、游戏内自定义事件),并转发到 VCP 前端广播。与 GodotBridge(请求-响应)物理隔离。", + "version": "1.0.0", + "author": "VCP Team", + "icon": "extension", + "category": "service", + "downloadUrl": "https://raw.githubusercontent.com/lioensky/VCPDistributedServer/main/Plugin/GodotEventReceiver/GodotEventReceiver.zip" + }, { "name": "GrokVideoGen", "displayName": "Grok 视频生成器",