Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3dc45468b | ||
|
|
63c18b0b5f |
@@ -75,3 +75,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run release metadata checks
|
||||||
|
run: npm run release:check
|
||||||
|
|
||||||
|
- name: Run npm package dry-run
|
||||||
|
run: npm run pack:dry-run
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ Library/
|
|||||||
library/
|
library/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
.release-tmp/
|
||||||
|
releases/
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- Added a documented release workflow and release checklist for Cocos extension publishing.
|
||||||
|
- Added release packaging scripts that generate a Cocos extension zip, release manifest, checksum file, and per-release README.
|
||||||
|
- Added CI validation for release metadata.
|
||||||
|
|
||||||
## [0.3.0] - 2026-05-20
|
## [0.3.0] - 2026-05-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
|
|||||||
|
|
||||||
Then restart Cocos Creator or reload extensions from the editor.
|
Then restart Cocos Creator or reload extensions from the editor.
|
||||||
|
|
||||||
|
For a non-git install, download `Funplay.CocosMcp.v<version>.zip` from the GitHub Releases page, unzip it, and move the extracted `funplay-cocos-mcp` folder into your project `extensions/` directory.
|
||||||
|
|
||||||
You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
|
You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
|
||||||
|
|
||||||
### 2. Start the MCP Server
|
### 2. Start the MCP Server
|
||||||
@@ -177,6 +179,31 @@ url = "http://127.0.0.1:8765/"
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 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
|
### 4. Verify the Connection
|
||||||
|
|
||||||
Open your AI client and try a few safe requests first:
|
Open your AI client and try a few safe requests first:
|
||||||
@@ -362,10 +389,27 @@ The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, res
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Run a syntax check before publishing changes:
|
Run checks before publishing changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
|
npm test
|
||||||
|
npm run release:check
|
||||||
|
npm run pack:dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
To generate a GitHub Release-ready extension package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package
|
||||||
|
```
|
||||||
|
|
||||||
|
The package is written to `releases/<version>/` 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
|
## License
|
||||||
|
|||||||
+45
-1
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
|
|||||||
|
|
||||||
然后重启 Cocos Creator,或在编辑器里重新加载扩展。
|
然后重启 Cocos Creator,或在编辑器里重新加载扩展。
|
||||||
|
|
||||||
|
如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v<version>.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。
|
||||||
|
|
||||||
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
|
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
|
||||||
|
|
||||||
### 2. 启动 MCP Server
|
### 2. 启动 MCP Server
|
||||||
@@ -177,6 +179,31 @@ url = "http://127.0.0.1:8765/"
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 可选: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. 验证连接
|
### 4. 验证连接
|
||||||
|
|
||||||
先在 AI 客户端里试几个安全请求:
|
先在 AI 客户端里试几个安全请求:
|
||||||
@@ -362,10 +389,27 @@ Cocos Creator Extension
|
|||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
发布改动前可以跑语法检查:
|
发布改动前可以跑检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
|
npm test
|
||||||
|
npm run release:check
|
||||||
|
npm run pack:dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
生成可上传到 GitHub Release 的扩展包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package
|
||||||
|
```
|
||||||
|
|
||||||
|
产物会写入 `releases/<version>/`,包含 zip、manifest、checksum 和 release README。完整流程见 [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) 和 [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md)。
|
||||||
|
|
||||||
|
发布 MCP Registry 前可以验证元数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run registry:validate
|
||||||
```
|
```
|
||||||
|
|
||||||
## 协议
|
## 协议
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Release Checklist
|
||||||
|
|
||||||
|
Use this checklist before publishing a new release of Funplay MCP for Cocos.
|
||||||
|
|
||||||
|
## 1. Repository Hygiene
|
||||||
|
|
||||||
|
- [ ] `git status` contains only intended release changes
|
||||||
|
- [ ] No tracked local junk is present (`.DS_Store`, `.idea/`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`)
|
||||||
|
- [ ] `package.json` version matches the intended release
|
||||||
|
- [ ] `CHANGELOG.md` includes the release notes for the target version
|
||||||
|
- [ ] `README.md` and `README_CN.md` match the current product behavior
|
||||||
|
|
||||||
|
## 2. Automated Verification
|
||||||
|
|
||||||
|
- [ ] `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/<version>/`
|
||||||
|
- [ ] `shasum -a 256 -c releases/<version>/SHA256SUMS.txt` passes
|
||||||
|
|
||||||
|
## 3. Package Contents
|
||||||
|
|
||||||
|
- [ ] The zip is named `Funplay.CocosMcp.v<version>.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
|
||||||
|
- [ ] `SHA256SUMS.txt` includes the zip, manifest, and release README
|
||||||
|
|
||||||
|
## 4. Cocos Smoke Test
|
||||||
|
|
||||||
|
- [ ] Test in a clean Cocos Creator `3.8+` project
|
||||||
|
- [ ] Install from the generated zip into `<project>/extensions/funplay-cocos-mcp`
|
||||||
|
- [ ] Restart Cocos Creator or reload extensions
|
||||||
|
- [ ] Open `Funplay > MCP Server`
|
||||||
|
- [ ] Start the MCP server successfully
|
||||||
|
- [ ] If the configured port is already in use, verify automatic fallback is reported clearly
|
||||||
|
- [ ] Run a read-only tool such as `get_project_info`
|
||||||
|
- [ ] Run a scene inspection tool such as `get_scene_info`
|
||||||
|
- [ ] Run a screenshot tool when the editor has a visible scene or preview
|
||||||
|
- [ ] Verify interaction logs appear in the MCP Server panel
|
||||||
|
|
||||||
|
## 5. MCP Client Verification
|
||||||
|
|
||||||
|
- [ ] Verify at least one primary client can connect (`Claude Code`, `Cursor`, `Codex`, etc.)
|
||||||
|
- [ ] 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. 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<version>`
|
||||||
|
- [ ] Tag is `v<version>`
|
||||||
|
- [ ] GitHub Release title is `v<version>`
|
||||||
|
- [ ] GitHub Release includes the zip, manifest, checksum file, and release README
|
||||||
|
- [ ] Public GitHub Release page renders the release notes and assets correctly
|
||||||
|
|
||||||
|
## 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@<version>`
|
||||||
|
- [ ] Publish MCP Registry metadata with `mcp-publisher publish server.json`
|
||||||
|
- [ ] Verify MCP Registry latest and specific-version endpoints
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Funplay Cocos MCP Release Workflow
|
||||||
|
|
||||||
|
This document records the release workflow for publishing Funplay MCP for Cocos to:
|
||||||
|
|
||||||
|
- Git tags
|
||||||
|
- GitHub Releases
|
||||||
|
- Downloadable Cocos Creator extension zip packages
|
||||||
|
- npm stdio wrapper package
|
||||||
|
- Official MCP Registry
|
||||||
|
|
||||||
|
## Published Identity
|
||||||
|
|
||||||
|
- GitHub repository: `https://github.com/FunplayAI/funplay-cocos-mcp`
|
||||||
|
- Git tag format: `v<version>`
|
||||||
|
- GitHub Release tag: `v<version>`
|
||||||
|
- Extension package asset: `Funplay.CocosMcp.v<version>.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
|
||||||
|
|
||||||
|
Keep these versions aligned:
|
||||||
|
|
||||||
|
- `package.json` `version`
|
||||||
|
- `CHANGELOG.md` release section
|
||||||
|
- Git tag `v<version>`
|
||||||
|
- GitHub Release `v<version>`
|
||||||
|
- `releases/<version>/release-manifest.json` `version`
|
||||||
|
- Release zip filename `Funplay.CocosMcp.v<version>.zip`
|
||||||
|
- `server.json` top-level version
|
||||||
|
- `server.json` npm package version
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- `package.json`: `0.3.1`
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
1. `package.json`
|
||||||
|
- `"version": "<version>"`
|
||||||
|
2. `CHANGELOG.md`
|
||||||
|
- add a dated release notes block
|
||||||
|
3. `server.json`
|
||||||
|
- top-level `"version"`
|
||||||
|
- npm package `"version"`
|
||||||
|
|
||||||
|
Optional but recommended:
|
||||||
|
|
||||||
|
4. `README.md`
|
||||||
|
5. `README_CN.md`
|
||||||
|
6. GitHub Release notes text
|
||||||
|
|
||||||
|
## Release Steps
|
||||||
|
|
||||||
|
### 1. Verify Working Tree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
The tree should contain only intentional release changes.
|
||||||
|
|
||||||
|
### 2. Update Versions And Notes
|
||||||
|
|
||||||
|
Update `package.json` and `CHANGELOG.md`.
|
||||||
|
|
||||||
|
Use semantic versions such as `0.3.1`, and keep release headings in this format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [0.3.1] - 2026-05-20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Release Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:verify
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```text
|
||||||
|
releases/<version>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected contents:
|
||||||
|
|
||||||
|
- `Funplay.CocosMcp.v<version>.zip`
|
||||||
|
- `release-manifest.json`
|
||||||
|
- `SHA256SUMS.txt`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
### 4. Inspect The Package
|
||||||
|
|
||||||
|
The release script validates that every archive path stays under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
funplay-cocos-mcp/
|
||||||
|
```
|
||||||
|
|
||||||
|
The package must not contain local/build content such as:
|
||||||
|
|
||||||
|
- `.git/`
|
||||||
|
- `.github/`
|
||||||
|
- `.DS_Store`
|
||||||
|
- `node_modules/`
|
||||||
|
- `Library/`
|
||||||
|
- `Temp/`
|
||||||
|
- `dist/`
|
||||||
|
- `build/`
|
||||||
|
- `test/`
|
||||||
|
- `scripts/`
|
||||||
|
|
||||||
|
Verify checksums:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd releases/<version>
|
||||||
|
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-<version>.tgz --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Commit, Tag, And Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Release v<version>"
|
||||||
|
git tag v<version>
|
||||||
|
git push origin main
|
||||||
|
git push origin v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Create GitHub Release
|
||||||
|
|
||||||
|
Regenerate the final release artifacts from the tagged clean commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package -- --strict-tag
|
||||||
|
```
|
||||||
|
|
||||||
|
If creating a new release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release create v<version> \
|
||||||
|
-R FunplayAI/funplay-cocos-mcp \
|
||||||
|
--title "v<version>" \
|
||||||
|
--notes-file /path/to/release-notes.md \
|
||||||
|
releases/<version>/Funplay.CocosMcp.v<version>.zip \
|
||||||
|
releases/<version>/release-manifest.json \
|
||||||
|
releases/<version>/SHA256SUMS.txt \
|
||||||
|
releases/<version>/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the release already exists and only assets need to be replaced:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release upload v<version> \
|
||||||
|
-R FunplayAI/funplay-cocos-mcp \
|
||||||
|
--clobber \
|
||||||
|
releases/<version>/Funplay.CocosMcp.v<version>.zip \
|
||||||
|
releases/<version>/release-manifest.json \
|
||||||
|
releases/<version>/SHA256SUMS.txt \
|
||||||
|
releases/<version>/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Verify GitHub Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release view v<version> \
|
||||||
|
-R FunplayAI/funplay-cocos-mcp \
|
||||||
|
--json url,assets,isDraft,isPrerelease,publishedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the release has all four assets.
|
||||||
|
|
||||||
|
### 8. Publish To npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the published package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view funplay-cocos-mcp@<version> 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/<version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Post-Release Smoke Test
|
||||||
|
|
||||||
|
Test the package from the public GitHub Release:
|
||||||
|
|
||||||
|
1. Download `Funplay.CocosMcp.v<version>.zip`.
|
||||||
|
2. Unzip it.
|
||||||
|
3. Move `funplay-cocos-mcp` into a Cocos project `extensions/` directory.
|
||||||
|
4. Restart Cocos Creator or reload extensions.
|
||||||
|
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<version> -R FunplayAI/funplay-cocos-mcp --json url,assets
|
||||||
|
npm view funplay-cocos-mcp@<version> version bin mcpName
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Failure Cases
|
||||||
|
|
||||||
|
### Release validation says the changelog section is missing
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- `CHANGELOG.md` does not contain `## [<version>] - YYYY-MM-DD`.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Add a dated release section before packaging.
|
||||||
|
|
||||||
|
### `zip` command is missing
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- The local environment does not have the `zip` CLI installed.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Install `zip`, then rerun `npm run release:package`.
|
||||||
|
|
||||||
|
### GitHub Release upload replaces the wrong assets
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- The version directory or release tag does not match `package.json` version.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Rerun `npm run release:check`.
|
||||||
|
- Confirm the command uses `releases/<version>/` and `v<version>`.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
Executable
+356
@@ -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 <http://127.0.0.1:8765/> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
+49
-3
@@ -1,14 +1,60 @@
|
|||||||
{
|
{
|
||||||
"name": "funplay-cocos-mcp",
|
"name": "funplay-cocos-mcp",
|
||||||
"package_version": 2,
|
"package_version": 2,
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
||||||
"author": "Funplay",
|
"author": "Funplay",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "browser.js",
|
"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": {
|
"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",
|
"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"
|
"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 pack:dry-run && npm run release:package"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"default": {
|
"default": {
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const childProcess = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PACKAGE_DIR_NAME = 'funplay-cocos-mcp';
|
||||||
|
const RELEASES_DIR = path.join(ROOT, 'releases');
|
||||||
|
const TEMP_DIR = path.join(ROOT, '.release-tmp');
|
||||||
|
const ZIP_PREFIX = 'Funplay.CocosMcp';
|
||||||
|
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
|
||||||
|
|
||||||
|
const REQUIRED_REPO_FILES = [
|
||||||
|
'package.json',
|
||||||
|
'README.md',
|
||||||
|
'README_CN.md',
|
||||||
|
'RELEASE_WORKFLOW.md',
|
||||||
|
'RELEASE_CHECKLIST.md',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'CONTRIBUTING.md',
|
||||||
|
'LICENSE',
|
||||||
|
'server.json',
|
||||||
|
'bin/funplay-cocos-mcp.js',
|
||||||
|
'browser.js',
|
||||||
|
'scene.js',
|
||||||
|
'panel/index.js',
|
||||||
|
'lib/server.js',
|
||||||
|
'lib/tool-registry.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
const PACKAGE_INCLUDES = [
|
||||||
|
'package.json',
|
||||||
|
'README.md',
|
||||||
|
'README_CN.md',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'CONTRIBUTING.md',
|
||||||
|
'LICENSE',
|
||||||
|
'server.json',
|
||||||
|
'bin',
|
||||||
|
'browser.js',
|
||||||
|
'scene.js',
|
||||||
|
'panel',
|
||||||
|
'lib'
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORBIDDEN_TRACKED_SEGMENTS = new Set([
|
||||||
|
'.idea',
|
||||||
|
'node_modules',
|
||||||
|
'Library',
|
||||||
|
'library',
|
||||||
|
'Temp',
|
||||||
|
'temp',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'coverage',
|
||||||
|
'releases',
|
||||||
|
'.release-tmp'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FORBIDDEN_ARCHIVE_SEGMENTS = new Set([
|
||||||
|
'.git',
|
||||||
|
'.github',
|
||||||
|
'.idea',
|
||||||
|
'node_modules',
|
||||||
|
'Library',
|
||||||
|
'library',
|
||||||
|
'Temp',
|
||||||
|
'temp',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'coverage',
|
||||||
|
'releases',
|
||||||
|
'.release-tmp',
|
||||||
|
'scripts',
|
||||||
|
'test'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FORBIDDEN_NAMES = new Set([
|
||||||
|
'.DS_Store'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const command = process.argv[2] || 'check';
|
||||||
|
const options = parseOptions(process.argv.slice(3));
|
||||||
|
|
||||||
|
if (command === 'check') {
|
||||||
|
const context = checkRelease(options);
|
||||||
|
console.log(`Release check passed for v${context.version}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'package') {
|
||||||
|
const context = checkRelease(options);
|
||||||
|
const artifacts = packageRelease(context);
|
||||||
|
console.log(`Release package ready: ${path.relative(ROOT, artifacts.releaseDir)}`);
|
||||||
|
console.log(`- ${artifacts.zipName}`);
|
||||||
|
console.log('- release-manifest.json');
|
||||||
|
console.log('- SHA256SUMS.txt');
|
||||||
|
console.log('- README.md');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printUsage();
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(args) {
|
||||||
|
const options = {
|
||||||
|
version: '',
|
||||||
|
strictTag: false
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--version' && args[i + 1]) {
|
||||||
|
options.version = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (arg === '--strict-tag') {
|
||||||
|
options.strictTag = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown release option: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRelease(options = {}) {
|
||||||
|
const errors = [];
|
||||||
|
const packageJson = readJson(path.join(ROOT, 'package.json'), errors);
|
||||||
|
const version = options.version || (packageJson && packageJson.version) || '';
|
||||||
|
const tag = `v${version}`;
|
||||||
|
|
||||||
|
if (!packageJson) {
|
||||||
|
throwErrors(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.version && options.version !== packageJson.version) {
|
||||||
|
errors.push(`--version ${options.version} does not match package.json version ${packageJson.version}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageJson.name !== 'funplay-cocos-mcp') {
|
||||||
|
errors.push('package.json name must be funplay-cocos-mcp.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(packageJson.package_version) || packageJson.package_version <= 0) {
|
||||||
|
errors.push('package.json package_version must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!packageJson.main || !fs.existsSync(path.join(ROOT, packageJson.main))) {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelogPath = path.join(ROOT, 'CHANGELOG.md');
|
||||||
|
const changelog = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
|
||||||
|
if (version && !new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}`, 'm').test(changelog)) {
|
||||||
|
errors.push(`CHANGELOG.md is missing a dated ## [${version}] release section.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackedFiles = gitLines(['ls-files']);
|
||||||
|
const forbiddenTracked = trackedFiles.filter(isForbiddenTrackedPath);
|
||||||
|
if (forbiddenTracked.length > 0) {
|
||||||
|
errors.push(`Tracked local/build junk must not be committed:\n- ${forbiddenTracked.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relative of PACKAGE_INCLUDES) {
|
||||||
|
const fullPath = path.join(ROOT, relative);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
errors.push(`Package include path is missing: ${relative}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.strictTag && !gitTagExists(tag)) {
|
||||||
|
errors.push(`Git tag ${tag} does not exist. Create it before publishing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwErrors(errors);
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageJson,
|
||||||
|
version,
|
||||||
|
tag,
|
||||||
|
changelogNotes: extractChangelogNotes(changelog, version),
|
||||||
|
gitCommit: gitText(['rev-parse', 'HEAD']).trim(),
|
||||||
|
gitDirty: gitText(['status', '--porcelain']).trim() !== ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageRelease(context) {
|
||||||
|
ensureCommand('zip');
|
||||||
|
|
||||||
|
const releaseDir = path.join(RELEASES_DIR, context.version);
|
||||||
|
const stagingRoot = path.join(TEMP_DIR, PACKAGE_DIR_NAME);
|
||||||
|
const zipName = `${ZIP_PREFIX}.v${context.version}.zip`;
|
||||||
|
const zipPath = path.join(releaseDir, zipName);
|
||||||
|
|
||||||
|
fs.rmSync(releaseDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(releaseDir, { recursive: true });
|
||||||
|
fs.mkdirSync(stagingRoot, { recursive: true });
|
||||||
|
|
||||||
|
for (const relative of PACKAGE_INCLUDES) {
|
||||||
|
copyIntoPackage(relative, stagingRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedFiles = collectFiles(stagingRoot)
|
||||||
|
.map((filePath) => path.relative(TEMP_DIR, filePath).split(path.sep).join('/'));
|
||||||
|
validateArchivePaths(stagedFiles);
|
||||||
|
|
||||||
|
run('zip', ['-qr', zipPath, PACKAGE_DIR_NAME], { cwd: TEMP_DIR });
|
||||||
|
validateZipListing(zipPath);
|
||||||
|
|
||||||
|
const zipSha256 = sha256File(zipPath);
|
||||||
|
const zipSize = fs.statSync(zipPath).size;
|
||||||
|
const manifest = buildManifest(context, {
|
||||||
|
zipName,
|
||||||
|
zipSha256,
|
||||||
|
zipSize,
|
||||||
|
fileCount: stagedFiles.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestPath = path.join(releaseDir, 'release-manifest.json');
|
||||||
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
const readmePath = path.join(releaseDir, 'README.md');
|
||||||
|
fs.writeFileSync(readmePath, buildReleaseReadme(context, manifest));
|
||||||
|
|
||||||
|
const checksums = [
|
||||||
|
checksumLine(zipPath, zipName),
|
||||||
|
checksumLine(manifestPath, 'release-manifest.json'),
|
||||||
|
checksumLine(readmePath, 'README.md')
|
||||||
|
].join('');
|
||||||
|
fs.writeFileSync(path.join(releaseDir, 'SHA256SUMS.txt'), checksums);
|
||||||
|
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
releaseDir,
|
||||||
|
zipName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyIntoPackage(relative, stagingRoot) {
|
||||||
|
const source = path.join(ROOT, relative);
|
||||||
|
const destination = path.join(stagingRoot, relative);
|
||||||
|
fs.cpSync(source, destination, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
filter(sourcePath) {
|
||||||
|
const name = path.basename(sourcePath);
|
||||||
|
if (FORBIDDEN_NAMES.has(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const relativeSource = path.relative(ROOT, sourcePath).split(path.sep);
|
||||||
|
return !relativeSource.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManifest(context, artifact) {
|
||||||
|
return {
|
||||||
|
version: context.version,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
repository: {
|
||||||
|
url: REPOSITORY_URL,
|
||||||
|
source: 'github'
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
tag: context.tag,
|
||||||
|
commit: context.gitCommit,
|
||||||
|
dirty: context.gitDirty
|
||||||
|
},
|
||||||
|
package: {
|
||||||
|
name: context.packageJson.name,
|
||||||
|
version: context.packageJson.version,
|
||||||
|
main: context.packageJson.main,
|
||||||
|
packageVersion: context.packageJson.package_version
|
||||||
|
},
|
||||||
|
artifacts: {
|
||||||
|
extensionZip: {
|
||||||
|
file: artifact.zipName,
|
||||||
|
sha256: artifact.zipSha256,
|
||||||
|
sizeBytes: artifact.zipSize,
|
||||||
|
fileCount: artifact.fileCount,
|
||||||
|
installDirectory: 'extensions/funplay-cocos-mcp',
|
||||||
|
githubDownloadUrl: `${REPOSITORY_URL}/releases/download/${context.tag}/${artifact.zipName}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: firstMeaningfulLine(context.changelogNotes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReleaseReadme(context, manifest) {
|
||||||
|
const zip = manifest.artifacts.extensionZip;
|
||||||
|
return `# Funplay MCP for Cocos ${context.tag}
|
||||||
|
|
||||||
|
This folder contains the generated release artifacts for Funplay MCP for Cocos ${context.tag}.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- \`${zip.file}\` - Cocos Creator extension package.
|
||||||
|
- \`release-manifest.json\` - Machine-readable release metadata.
|
||||||
|
- \`SHA256SUMS.txt\` - SHA-256 checksums for release artifacts.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Unzip \`${zip.file}\`.
|
||||||
|
2. Move the extracted \`${PACKAGE_DIR_NAME}\` folder into your Cocos project \`extensions/\` directory.
|
||||||
|
3. Restart Cocos Creator or reload extensions.
|
||||||
|
4. Open \`Funplay > MCP Server\`.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
shasum -a 256 -c SHA256SUMS.txt
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchivePaths(paths) {
|
||||||
|
const bad = [];
|
||||||
|
const prefix = `${PACKAGE_DIR_NAME}/`;
|
||||||
|
|
||||||
|
for (const archivePath of paths) {
|
||||||
|
const normalized = archivePath.replace(/\\/g, '/');
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (!normalized.startsWith(prefix)) {
|
||||||
|
bad.push(`${archivePath} (must stay under ${PACKAGE_DIR_NAME}/)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parts.some((part) => part === '..' || part === '.')) {
|
||||||
|
bad.push(`${archivePath} (contains unsafe relative path segments)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parts.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part))) {
|
||||||
|
bad.push(`${archivePath} (contains forbidden release content)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bad.length > 0) {
|
||||||
|
throw new Error(`Release archive contains invalid paths:\n- ${bad.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateZipListing(zipPath) {
|
||||||
|
const result = childProcess.spawnSync('unzip', ['-Z1', zipPath], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error && result.error.code === 'ENOENT') {
|
||||||
|
console.warn('Warning: unzip is not available; skipped zip listing validation.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`Failed to inspect ${zipPath}:\n${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = result.stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
validateArchivePaths(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenTrackedPath(relative) {
|
||||||
|
const parts = relative.split('/');
|
||||||
|
return parts.some((part) => FORBIDDEN_TRACKED_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath, errors) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${path.relative(ROOT, filePath)} is not valid JSON: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFiles(directory) {
|
||||||
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...collectFiles(fullPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChangelogNotes(changelog, version) {
|
||||||
|
const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}\\s*$`, 'm');
|
||||||
|
const match = heading.exec(changelog);
|
||||||
|
if (!match) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
const rest = changelog.slice(start);
|
||||||
|
const next = rest.search(/^## /m);
|
||||||
|
return (next >= 0 ? rest.slice(0, next) : rest).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMeaningfulLine(text) {
|
||||||
|
const line = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find((value) => value && !value.startsWith('###') && !value.startsWith('-'));
|
||||||
|
if (line) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
const bullet = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find((value) => value.startsWith('- '));
|
||||||
|
return bullet ? bullet.slice(2) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumLine(filePath, displayName) {
|
||||||
|
return `${sha256File(filePath)} ${displayName}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(filePath) {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCommand(name) {
|
||||||
|
const result = childProcess.spawnSync(name, ['-v'], { encoding: 'utf8' });
|
||||||
|
if (result.error && result.error.code === 'ENOENT') {
|
||||||
|
throw new Error(`Required command not found: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = childProcess.spawnSync(command, args, {
|
||||||
|
cwd: options.cwd || ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitLines(args) {
|
||||||
|
const text = gitText(args);
|
||||||
|
return text ? text.split(/\r?\n/).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitText(args) {
|
||||||
|
const result = childProcess.spawnSync('git', args, {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitTagExists(tag) {
|
||||||
|
const result = childProcess.spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwErrors(errors) {
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Release validation failed:\n- ${errors.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.error(`Usage:
|
||||||
|
node scripts/release.js check [--version <version>] [--strict-tag]
|
||||||
|
node scripts/release.js package [--version <version>] [--strict-tag]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user