diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18dcf4f..32c4d27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,6 @@ jobs: - name: Run release metadata checks run: npm run release:check + + - name: Run npm package dry-run + run: npm run pack:dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e12ee3..3a915f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht ## [Unreleased] +## [0.3.2] - 2026-05-20 + +### Added + +- Added an npm-installable `funplay-cocos-mcp` stdio wrapper that bridges MCP clients to the local Cocos HTTP endpoint. +- Added MCP Registry metadata in `server.json`, including npm package ownership metadata via `mcpName`. +- Added wrapper tests, npm pack dry-run verification, and registry validation scripts. + ## [0.3.1] - 2026-05-20 ### Added diff --git a/README.md b/README.md index ff9bc09..9259f45 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,31 @@ url = "http://127.0.0.1:8765/" +### Optional: npm stdio Wrapper + +If your MCP client prefers a local `stdio` command, install the npm wrapper after starting the Cocos editor server: + +```bash +npm install -g funplay-cocos-mcp +``` + +Example MCP client entry: + +```json +{ + "mcpServers": { + "funplay_cocos": { + "command": "funplay-cocos-mcp", + "env": { + "FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/" + } + } + } +} +``` + +The wrapper bridges stdio MCP traffic to the embedded Cocos HTTP endpoint. You can also run it with `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`. + ### 4. Verify the Connection Open your AI client and try a few safe requests first: @@ -370,6 +395,7 @@ Run checks before publishing changes: npm run check npm test npm run release:check +npm run pack:dry-run ``` To generate a GitHub Release-ready extension package: @@ -380,6 +406,12 @@ npm run release:package The package is written to `releases//` with a zip, manifest, checksum file, and release README. See [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) and [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) for the full process. +Validate MCP Registry metadata before publishing: + +```bash +npm run registry:validate +``` + ## License MIT License. See [LICENSE](./LICENSE). diff --git a/README_CN.md b/README_CN.md index c975295..2166b4b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -179,6 +179,31 @@ url = "http://127.0.0.1:8765/" +### 可选:npm stdio Wrapper + +如果 MCP 客户端更适合使用本地 `stdio` 命令,可以在启动 Cocos 编辑器内置服务后安装 npm wrapper: + +```bash +npm install -g funplay-cocos-mcp +``` + +示例 MCP 客户端配置: + +```json +{ + "mcpServers": { + "funplay_cocos": { + "command": "funplay-cocos-mcp", + "env": { + "FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/" + } + } + } +} +``` + +这个 wrapper 会把 stdio MCP 流量桥接到 Cocos 内置 HTTP endpoint。也可以直接运行 `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`。 + ### 4. 验证连接 先在 AI 客户端里试几个安全请求: @@ -370,6 +395,7 @@ Cocos Creator Extension npm run check npm test npm run release:check +npm run pack:dry-run ``` 生成可上传到 GitHub Release 的扩展包: @@ -380,6 +406,12 @@ npm run release:package 产物会写入 `releases//`,包含 zip、manifest、checksum 和 release README。完整流程见 [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) 和 [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md)。 +发布 MCP Registry 前可以验证元数据: + +```bash +npm run registry:validate +``` + ## 协议 MIT License。详见 [LICENSE](./LICENSE)。 diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index ec9fc15..86a365e 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -15,6 +15,8 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos. - [ ] `npm run check` passes - [ ] `npm test` passes - [ ] `npm run release:check` passes +- [ ] `npm run pack:dry-run` passes +- [ ] `npm run registry:validate` passes when `mcp-publisher` is available - [ ] `npm run release:package` creates `releases//` - [ ] `shasum -a 256 -c releases//SHA256SUMS.txt` passes @@ -23,6 +25,7 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos. - [ ] The zip is named `Funplay.CocosMcp.v.zip` - [ ] The zip contains a single top-level `funplay-cocos-mcp/` folder - [ ] The zip contains runtime files: `package.json`, `browser.js`, `scene.js`, `panel/`, and `lib/` +- [ ] The zip contains stdio wrapper metadata: `bin/funplay-cocos-mcp.js` and `server.json` - [ ] The zip includes docs: `README.md`, `README_CN.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, and `LICENSE` - [ ] The zip does not contain `.git/`, `.github/`, `.DS_Store`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`, `test/`, or `scripts/` - [ ] `release-manifest.json` references the correct GitHub download URL @@ -47,8 +50,21 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos. - [ ] Confirm `tools/list` returns the expected `core` profile tools - [ ] Confirm a tool call succeeds end-to-end from the external client - [ ] Verify one-click config output still matches the documented config snippets +- [ ] Verify the stdio wrapper can connect with `funplay-cocos-mcp --url http://127.0.0.1:8765/` -## 6. GitHub Release Readiness +## 6. npm And MCP Registry Readiness + +- [ ] `package.json` `name` is `funplay-cocos-mcp` +- [ ] `package.json` `version` matches `server.json` version +- [ ] `package.json` `mcpName` matches `server.json` name +- [ ] `package.json` `bin.funplay-cocos-mcp` points to `bin/funplay-cocos-mcp.js` +- [ ] `server.json` npm package identifier and version match `package.json` +- [ ] `server.json` npm transport type is `stdio` +- [ ] npm package dry-run includes `bin/`, `lib/`, `panel/`, `browser.js`, `scene.js`, and `server.json` +- [ ] npm credentials are available for `npm publish` +- [ ] MCP Registry credentials are available for `mcp-publisher publish` + +## 7. GitHub Release Readiness - [ ] CI passes on `main` - [ ] Release commit message is `Release v` @@ -57,17 +73,22 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos. - [ ] GitHub Release includes the zip, manifest, checksum file, and release README - [ ] Public GitHub Release page renders the release notes and assets correctly -## 7. Publish +## 8. Publish - [ ] Commit the release changes - [ ] Create and push the release tag - [ ] Create or update the GitHub Release - [ ] Upload generated release assets - [ ] Verify the GitHub Release asset list +- [ ] Publish npm package with `npm publish` +- [ ] Verify npm package with `npm view funplay-cocos-mcp@` +- [ ] Publish MCP Registry metadata with `mcp-publisher publish server.json` +- [ ] Verify MCP Registry latest and specific-version endpoints -## 8. Post-Release +## 9. Post-Release - [ ] Re-test installation from the public GitHub Release zip +- [ ] Re-test stdio wrapper installation from npm - [ ] Check the update checker reports the new latest version - [ ] Check README install instructions and download links - [ ] Announce the release where appropriate diff --git a/RELEASE_WORKFLOW.md b/RELEASE_WORKFLOW.md index ebd82f3..f122f7c 100644 --- a/RELEASE_WORKFLOW.md +++ b/RELEASE_WORKFLOW.md @@ -5,6 +5,8 @@ This document records the release workflow for publishing Funplay MCP for Cocos - Git tags - GitHub Releases - Downloadable Cocos Creator extension zip packages +- npm stdio wrapper package +- Official MCP Registry ## Published Identity @@ -13,6 +15,9 @@ This document records the release workflow for publishing Funplay MCP for Cocos - GitHub Release tag: `v` - Extension package asset: `Funplay.CocosMcp.v.zip` - Cocos extension folder name inside the zip: `funplay-cocos-mcp` +- npm package id: `funplay-cocos-mcp` +- npm command: `funplay-cocos-mcp` +- MCP Registry server name: `io.github.FunplayAI/funplay-cocos-mcp` - Default local MCP endpoint: `http://127.0.0.1:8765/` ## Version Alignment Rule @@ -25,6 +30,8 @@ Keep these versions aligned: - GitHub Release `v` - `releases//release-manifest.json` `version` - Release zip filename `Funplay.CocosMcp.v.zip` +- `server.json` top-level version +- `server.json` npm package version Example: @@ -32,6 +39,8 @@ Example: - Git tag: `v0.3.1` - GitHub Release: `v0.3.1` - Release asset: `Funplay.CocosMcp.v0.3.1.zip` +- npm package: `funplay-cocos-mcp@0.3.1` +- MCP Registry: `0.3.1` ## Files To Update For A New Release @@ -41,12 +50,15 @@ Update: - `"version": ""` 2. `CHANGELOG.md` - add a dated release notes block +3. `server.json` + - top-level `"version"` + - npm package `"version"` Optional but recommended: -3. `README.md` -4. `README_CN.md` -5. GitHub Release notes text +4. `README.md` +5. `README_CN.md` +6. GitHub Release notes text ## Release Steps @@ -79,6 +91,7 @@ This runs: - JavaScript syntax checks - Node.js tests - release metadata validation +- npm package dry-run validation - release package generation The generated local artifacts are written to: @@ -122,6 +135,19 @@ cd releases/ shasum -a 256 -c SHA256SUMS.txt ``` +### 4.5 Validate npm And MCP Registry Metadata + +```bash +npm run pack:dry-run +npm run registry:validate +``` + +The npm package must include the stdio wrapper command: + +```bash +npx --yes ./funplay-cocos-mcp-.tgz --version +``` + ### 5. Commit, Tag, And Push ```bash @@ -175,7 +201,51 @@ gh release view v \ Confirm the release has all four assets. -### 8. Post-Release Smoke Test +### 8. Publish To npm + +```bash +npm publish +``` + +Verify the published package: + +```bash +npm view funplay-cocos-mcp@ version bin mcpName +``` + +Notes: + +- `package.json` `mcpName` must match `server.json` `name`. +- If `npm publish` returns `ENEEDAUTH`, run `npm adduser` with a publishing account and retry. +- If the package name already exists under another owner, choose a scoped package name and update both `package.json` and `server.json`. + +### 9. Publish To MCP Registry + +Log in if needed: + +```bash +mcp-publisher login github +``` + +Publish: + +```bash +mcp-publisher publish server.json +``` + +Verify latest: + +```bash +curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.FunplayAI/funplay-cocos-mcp&version=latest" +``` + +Check a specific version: + +```bash +curl "https://registry.modelcontextprotocol.io/v0.1/servers/io.github.FunplayAI%2Ffunplay-cocos-mcp/versions/" +``` + +### 10. Post-Release Smoke Test Test the package from the public GitHub Release: @@ -186,12 +256,16 @@ Test the package from the public GitHub Release: 5. Open `Funplay > MCP Server`. 6. Start the MCP server. 7. Connect an MCP client and call `get_project_info`. +8. Install the npm wrapper with `npm install -g funplay-cocos-mcp`. +9. Connect an MCP client through the `funplay-cocos-mcp` command and call `tools/list`. ## Current Verification Commands ```bash npm run release:verify +npm run registry:validate gh release view v -R FunplayAI/funplay-cocos-mcp --json url,assets +npm view funplay-cocos-mcp@ version bin mcpName ``` ## Common Failure Cases @@ -226,3 +300,30 @@ Fix: - Rerun `npm run release:check`. - Confirm the command uses `releases//` and `v`. + +### npm `ENEEDAUTH` + +Cause: + +- The local machine is not logged in to npm. + +Fix: + +```bash +npm adduser +npm publish +``` + +### MCP Registry `Package validation failed` + +Cause: + +- npm package has not been published yet. +- `package.json` `mcpName` does not match `server.json` `name`. +- `server.json` package version does not match the npm package version. + +Fix: + +- Publish the npm package first. +- Rerun `npm run release:check`. +- Rerun `npm run registry:validate`. diff --git a/bin/funplay-cocos-mcp.js b/bin/funplay-cocos-mcp.js new file mode 100755 index 0000000..f1a853d --- /dev/null +++ b/bin/funplay-cocos-mcp.js @@ -0,0 +1,356 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); + +const DEFAULT_URL = 'http://127.0.0.1:8765/'; +const DEFAULT_TIMEOUT_SECONDS = 120; +const ACCEPT_HEADER = 'application/json, text/event-stream'; + +async function main() { + const args = process.argv.slice(2); + + if (hasFlag(args, '--help') || hasFlag(args, '-h')) { + printHelp(); + return 0; + } + + if (hasFlag(args, '--version')) { + console.error(`funplay-cocos-mcp ${readPackageVersion()}`); + return 0; + } + + const urlText = getOption(args, '--url') + || process.env.FUNPLAY_COCOS_MCP_URL + || DEFAULT_URL; + const timeoutText = getOption(args, '--timeout-seconds') + || process.env.FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS + || String(DEFAULT_TIMEOUT_SECONDS); + + const endpoint = parseEndpoint(urlText); + if (!endpoint) { + console.error(`Invalid --url value: ${urlText}`); + return 2; + } + + const timeoutSeconds = Number.parseInt(timeoutText, 10); + if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) { + console.error(`Invalid timeout value: ${timeoutText}`); + return 2; + } + + console.error(`[Funplay Cocos MCP] Bridging stdio to ${endpoint.href}`); + await bridgeStdioToHttp({ + input: process.stdin, + output: process.stdout, + endpoint, + timeoutMs: timeoutSeconds * 1000 + }); + return 0; +} + +async function bridgeStdioToHttp({ input, output, endpoint, timeoutMs }) { + let sessionId = ''; + + while (true) { + const message = await readMessage(input); + if (message === null) { + return; + } + + let parsed = null; + let requestId = null; + try { + parsed = JSON.parse(message); + requestId = getRequestId(parsed); + } catch (error) { + await writeJsonRpcError(output, null, -32700, 'Parse error'); + continue; + } + + try { + const response = await postJsonRpc(endpoint, message, { + timeoutMs, + sessionId + }); + + const nextSessionId = response.headers['mcp-session-id']; + if (typeof nextSessionId === 'string' && nextSessionId) { + sessionId = nextSessionId; + } + + if (response.statusCode >= 200 && response.statusCode < 300 && response.body.trim()) { + await writeMessage(output, response.body); + continue; + } + + if (response.statusCode >= 200 && response.statusCode < 300 && isNotification(parsed)) { + continue; + } + + if (requestId !== null) { + const messageText = response.body.trim() + ? `Cocos MCP server returned HTTP ${response.statusCode}: ${response.body}` + : `Cocos MCP server returned HTTP ${response.statusCode}.`; + await writeJsonRpcError(output, requestId, -32000, messageText); + } + } catch (error) { + console.error(`[Funplay Cocos MCP] ${error.message}`); + if (requestId !== null) { + await writeJsonRpcError(output, requestId, -32000, `Proxy transport error: ${error.message}`); + } + } + } +} + +function postJsonRpc(endpoint, body, { timeoutMs, sessionId }) { + return new Promise((resolve, reject) => { + const client = endpoint.protocol === 'https:' ? https : http; + const request = client.request( + endpoint, + { + method: 'POST', + headers: { + Accept: ACCEPT_HEADER, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}) + }, + timeout: timeoutMs + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + resolve({ + statusCode: response.statusCode || 0, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8') + }); + }); + } + ); + + request.on('timeout', () => { + request.destroy(new Error(`HTTP request timed out after ${timeoutMs / 1000} seconds.`)); + }); + request.on('error', reject); + request.end(body); + }); +} + +function parseEndpoint(value) { + try { + const endpoint = new URL(value); + if (endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:') { + return null; + } + return endpoint; + } catch (error) { + return null; + } +} + +function getRequestId(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return Object.prototype.hasOwnProperty.call(value, 'id') ? value.id : null; +} + +function isNotification(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value) && value.method && !Object.prototype.hasOwnProperty.call(value, 'id')); +} + +async function writeJsonRpcError(output, id, code, message) { + await writeMessage(output, JSON.stringify({ + jsonrpc: '2.0', + id, + error: { + code, + message + } + })); +} + +async function readMessage(input) { + const headers = new Map(); + + while (true) { + const line = await readHeaderLine(input); + if (line === null) { + return headers.size === 0 ? null : Promise.reject(new Error('Unexpected EOF while reading MCP headers.')); + } + + if (line === '') { + break; + } + + const separator = line.indexOf(':'); + if (separator <= 0) { + continue; + } + + headers.set(line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()); + } + + const contentLength = Number.parseInt(headers.get('content-length') || '', 10); + if (!Number.isFinite(contentLength) || contentLength < 0) { + throw new Error('Missing Content-Length header.'); + } + + const payload = await readExact(input, contentLength); + return payload.toString('utf8'); +} + +function readHeaderLine(input) { + return new Promise((resolve, reject) => { + const chunks = []; + + function cleanup() { + input.off('readable', onReadable); + input.off('end', onEnd); + input.off('error', onError); + } + + function onReadable() { + let byte; + while ((byte = input.read(1)) !== null) { + if (byte[0] === 0x0a) { + cleanup(); + if (chunks.length > 0 && chunks[chunks.length - 1][0] === 0x0d) { + chunks.pop(); + } + resolve(Buffer.concat(chunks).toString('ascii')); + return; + } + chunks.push(byte); + } + } + + function onEnd() { + cleanup(); + resolve(chunks.length === 0 ? null : Buffer.concat(chunks).toString('ascii')); + } + + function onError(error) { + cleanup(); + reject(error); + } + + input.on('readable', onReadable); + input.once('end', onEnd); + input.once('error', onError); + onReadable(); + }); +} + +function readExact(input, length) { + return new Promise((resolve, reject) => { + const chunks = []; + let remaining = length; + + function cleanup() { + input.off('readable', onReadable); + input.off('end', onEnd); + input.off('error', onError); + } + + function onReadable() { + while (remaining > 0) { + const chunk = input.read(remaining); + if (chunk === null) { + return; + } + chunks.push(chunk); + remaining -= chunk.length; + } + cleanup(); + resolve(Buffer.concat(chunks, length)); + } + + function onEnd() { + cleanup(); + reject(new Error('Unexpected EOF while reading MCP payload.')); + } + + function onError(error) { + cleanup(); + reject(error); + } + + input.on('readable', onReadable); + input.once('end', onEnd); + input.once('error', onError); + onReadable(); + }); +} + +function writeMessage(output, json) { + const payload = Buffer.from(json, 'utf8'); + return new Promise((resolve, reject) => { + output.write(`Content-Length: ${payload.length}\r\n\r\n`, 'ascii', (headerError) => { + if (headerError) { + reject(headerError); + return; + } + output.write(payload, (payloadError) => { + if (payloadError) { + reject(payloadError); + return; + } + resolve(); + }); + }); + }); +} + +function hasFlag(args, name) { + return args.some((arg) => arg.toLowerCase() === name.toLowerCase()); +} + +function getOption(args, name) { + for (let i = 0; i < args.length - 1; i += 1) { + if (args[i].toLowerCase() === name.toLowerCase()) { + return args[i + 1]; + } + } + return ''; +} + +function readPackageVersion() { + try { + const packagePath = path.resolve(__dirname, '..', 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + return packageJson.version || '0.0.0'; + } catch (error) { + return '0.0.0'; + } +} + +function printHelp() { + console.error('funplay-cocos-mcp'); + console.error('Bridges stdio MCP traffic to a local Cocos Creator HTTP MCP server.'); + console.error(); + console.error('Options:'); + console.error(' --url Cocos MCP HTTP endpoint.'); + console.error(' --timeout-seconds <120> HTTP timeout per request.'); + console.error(' --version Print the proxy version.'); + console.error(' --help Show this help.'); + console.error(); + console.error('Environment:'); + console.error(' FUNPLAY_COCOS_MCP_URL'); + console.error(' FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS'); +} + +if (require.main === module) { + main().then((code) => { + process.exitCode = code; + }).catch((error) => { + console.error(`[Funplay Cocos MCP] ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/package.json b/package.json index e97121f..cbddbdd 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,60 @@ { "name": "funplay-cocos-mcp", "package_version": 2, - "version": "0.3.1", + "version": "0.3.2", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "author": "Funplay", "license": "MIT", "main": "browser.js", + "bin": { + "funplay-cocos-mcp": "bin/funplay-cocos-mcp.js" + }, + "mcpName": "io.github.FunplayAI/funplay-cocos-mcp", + "repository": { + "type": "git", + "url": "git+https://github.com/FunplayAI/funplay-cocos-mcp.git" + }, + "homepage": "https://github.com/FunplayAI/funplay-cocos-mcp#readme", + "bugs": { + "url": "https://github.com/FunplayAI/funplay-cocos-mcp/issues" + }, + "keywords": [ + "mcp", + "cocos", + "cocos-creator", + "funplay", + "model-context-protocol", + "ai" + ], + "engines": { + "node": ">=18" + }, + "files": [ + "bin/", + "lib/", + "panel/", + "browser.js", + "scene.js", + "server.json", + "README.md", + "README_CN.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "LICENSE", + "RELEASE_WORKFLOW.md", + "RELEASE_CHECKLIST.md" + ], + "publishConfig": { + "access": "public" + }, "scripts": { - "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/release.js", + "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/release.js", "test": "node --test", + "pack:dry-run": "npm pack --dry-run", + "registry:validate": "mcp-publisher validate server.json", "release:check": "node scripts/release.js check", "release:package": "node scripts/release.js package", - "release:verify": "npm run check && npm test && npm run release:check && npm run release:package" + "release:verify": "npm run check && npm test && npm run release:check && npm run pack:dry-run && npm run release:package" }, "panels": { "default": { diff --git a/scripts/release.js b/scripts/release.js index b51a140..097bf92 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -22,6 +22,8 @@ const REQUIRED_REPO_FILES = [ 'CHANGELOG.md', 'CONTRIBUTING.md', 'LICENSE', + 'server.json', + 'bin/funplay-cocos-mcp.js', 'browser.js', 'scene.js', 'panel/index.js', @@ -36,6 +38,8 @@ const PACKAGE_INCLUDES = [ 'CHANGELOG.md', 'CONTRIBUTING.md', 'LICENSE', + 'server.json', + 'bin', 'browser.js', 'scene.js', 'panel', @@ -150,10 +154,40 @@ function checkRelease(options = {}) { errors.push('package.json main must point to an existing file.'); } + if (!packageJson.bin || packageJson.bin['funplay-cocos-mcp'] !== 'bin/funplay-cocos-mcp.js') { + errors.push('package.json bin.funplay-cocos-mcp must point to bin/funplay-cocos-mcp.js.'); + } + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { errors.push(`package.json version must be semver-like, got: ${version}`); } + const serverJson = readJson(path.join(ROOT, 'server.json'), errors); + if (serverJson) { + if (serverJson.name !== packageJson.mcpName) { + errors.push('server.json name must match package.json mcpName.'); + } + if (serverJson.version !== version) { + errors.push(`server.json version ${serverJson.version} must match package.json version ${version}.`); + } + const npmPackage = Array.isArray(serverJson.packages) + ? serverJson.packages.find((entry) => entry && entry.registryType === 'npm') + : null; + if (!npmPackage) { + errors.push('server.json must include an npm package entry.'); + } else { + if (npmPackage.identifier !== packageJson.name) { + errors.push(`server.json npm identifier ${npmPackage.identifier} must match package.json name ${packageJson.name}.`); + } + if (npmPackage.version !== version) { + errors.push(`server.json npm package version ${npmPackage.version} must match package.json version ${version}.`); + } + if (!npmPackage.transport || npmPackage.transport.type !== 'stdio') { + errors.push('server.json npm package transport must be stdio.'); + } + } + } + for (const relative of REQUIRED_REPO_FILES) { if (!fs.existsSync(path.join(ROOT, relative))) { errors.push(`Missing required repository file: ${relative}`); diff --git a/server.json b/server.json new file mode 100644 index 0000000..0d0a5d2 --- /dev/null +++ b/server.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.FunplayAI/funplay-cocos-mcp", + "title": "Funplay Cocos MCP", + "description": "stdio bridge for the local Cocos Creator Editor MCP server from FunplayAI/funplay-cocos-mcp.", + "repository": { + "url": "https://github.com/FunplayAI/funplay-cocos-mcp", + "source": "github" + }, + "version": "0.3.2", + "packages": [ + { + "registryType": "npm", + "identifier": "funplay-cocos-mcp", + "version": "0.3.2", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "FUNPLAY_COCOS_MCP_URL", + "description": "Optional local Cocos MCP HTTP endpoint. Defaults to http://127.0.0.1:8765/.", + "format": "string", + "isRequired": false, + "isSecret": false + }, + { + "name": "FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS", + "description": "Optional HTTP timeout per request in seconds. Defaults to 120.", + "format": "string", + "isRequired": false, + "isSecret": false + } + ] + } + ] +} diff --git a/test/stdio-proxy.test.js b/test/stdio-proxy.test.js new file mode 100644 index 0000000..4c19453 --- /dev/null +++ b/test/stdio-proxy.test.js @@ -0,0 +1,176 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); +const http = require('node:http'); +const path = require('node:path'); +const test = require('node:test'); + +const ROOT = path.resolve(__dirname, '..'); +const WRAPPER = path.join(ROOT, 'bin', 'funplay-cocos-mcp.js'); +const PACKAGE = require('../package.json'); + +function writeFramed(stream, value) { + const payload = Buffer.from(JSON.stringify(value), 'utf8'); + stream.write(`Content-Length: ${payload.length}\r\n\r\n`); + stream.write(payload); +} + +function createFramedReader(stream) { + let buffer = Buffer.alloc(0); + const waiters = []; + + function readFromBuffer() { + const marker = buffer.indexOf('\r\n\r\n'); + if (marker < 0) { + return null; + } + + const header = buffer.slice(0, marker).toString('ascii'); + const match = /^content-length:\s*(\d+)$/im.exec(header); + if (!match) { + throw new Error(`Missing Content-Length header: ${header}`); + } + + const length = Number.parseInt(match[1], 10); + const start = marker + 4; + const end = start + length; + if (buffer.length < end) { + return null; + } + + const payload = buffer.slice(start, end).toString('utf8'); + buffer = buffer.slice(end); + return JSON.parse(payload); + } + + function pump() { + while (waiters.length > 0) { + const value = readFromBuffer(); + if (value === null) { + return; + } + waiters.shift().resolve(value); + } + } + + stream.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + pump(); + }); + stream.on('error', (error) => { + while (waiters.length > 0) { + waiters.shift().reject(error); + } + }); + + return function readNext() { + const value = readFromBuffer(); + if (value !== null) { + return Promise.resolve(value); + } + return new Promise((resolve, reject) => { + waiters.push({ resolve, reject }); + }); + }; +} + +function createHttpProxyTarget(handler) { + const server = http.createServer(handler); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + resolve({ + server, + url: `http://127.0.0.1:${server.address().port}/` + }); + }); + }); +} + +function readBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + request.on('error', reject); + }); +} + +function waitForExit(child) { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => resolve(code)); + }); +} + +test('stdio wrapper prints package version', () => { + const result = childProcess.spawnSync(process.execPath, [WRAPPER, '--version'], { + cwd: ROOT, + encoding: 'utf8' + }); + + assert.equal(result.status, 0); + assert.match(result.stderr, new RegExp(`funplay-cocos-mcp ${PACKAGE.version}`)); +}); + +test('stdio wrapper proxies framed JSON-RPC to the Cocos HTTP endpoint', async () => { + const requests = []; + const target = await createHttpProxyTarget(async (request, response) => { + const body = await readBody(request); + requests.push({ + headers: request.headers, + body: JSON.parse(body) + }); + + response.setHeader('Content-Type', 'application/json'); + if (requests.length === 1) { + response.setHeader('Mcp-Session-Id', 'session-1'); + } + response.end(JSON.stringify({ + jsonrpc: '2.0', + id: requests.length, + result: { ok: true, index: requests.length } + })); + }); + + const child = childProcess.spawn(process.execPath, [WRAPPER, '--url', target.url], { + cwd: ROOT, + stdio: ['pipe', 'pipe', 'pipe'] + }); + const readNext = createFramedReader(child.stdout); + + try { + writeFramed(child.stdin, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {} + }); + assert.deepEqual(await readNext(), { + jsonrpc: '2.0', + id: 1, + result: { ok: true, index: 1 } + }); + + writeFramed(child.stdin, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list' + }); + assert.deepEqual(await readNext(), { + jsonrpc: '2.0', + id: 2, + result: { ok: true, index: 2 } + }); + + assert.equal(requests.length, 2); + assert.equal(requests[0].headers.accept, 'application/json, text/event-stream'); + assert.equal(requests[1].headers['mcp-session-id'], 'session-1'); + assert.equal(requests[1].body.method, 'tools/list'); + } finally { + child.stdin.end(); + await waitForExit(child); + await new Promise((resolve) => target.server.close(resolve)); + } +});