From 3947c4b5ffe6f87591b29cb65369d684a0f4f3f8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 8 Sep 2026 12:54:19 +0900 Subject: [PATCH] feat(ci): ship one MCP Bundle per release, for every operating system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.0 release attached six loose binaries and no `.mcpb`. That leaves a user to work out which of the six matches their machine, put it somewhere, and wire stdio up by hand — for a server whose whole point is that a host launches it. The cross-builds were already there; nothing combined them. A `bundle` job now collects the same three server binaries the build matrix already produces onto one runner and packs them into a single `devup-mcp-.mcpb`. One file installs on any of the three, because `server.mcp_config.platform_overrides` — keyed by `linux`, `darwin` and `win32`, which is Node's `process.platform` and so exactly what the host looks itself up by — names the matching command at launch. Its one required `user_config` value is the workspace directory, which becomes the server's only writable root, so the install dialog asks for the thing that would otherwise be an unpredictable process cwd. Only the MCP server is carried: devup-mcp-visual is a comparison CLI a consumer repository runs in its own CI, not something a host ever launches. `finalize` waits on the bundle as well as the binaries, so a published release cannot be missing it, exactly as it already could not be missing a platform. Three checks hold the bundle to what it claims, each for a failure that is invisible until a user hits it. The manifest is schema-validated on every pull request. It was otherwise first exercised on a release run, where a malformed manifest is found after the tag and the draft already exist. Every command the manifest can select is confirmed to exist inside the staged archive. `mcpb validate` only ever inspects `server.entry_point`, so a typo in a `platform_overrides` path passes validation and ships a bundle broken on exactly one operating system and healthy on the two the runner can see. The packed archive is read back to confirm the two Unix binaries kept their executable bit. This is why the job must run on Linux: mcpb writes Unix permission bits into the zip only from a Unix host — from Windows it writes none at all — and the artifact round trip between jobs does not carry file modes either, so the bit has to be restored and then proved. Measured rather than assumed: mcpb stores `(mode & 0o777) << 16`, without the regular-file type bits, so zipinfo renders the type column as `?` even for a correct archive, and a check on a leading `-` would have rejected every good bundle instead of the bad ones. The assertion reads the owner execute position, and was run against four archives — packed Unix-executable, Unix-executable with type bits, Unix non-executable, and Windows-packed — passing the first two and failing the last two. The version comes from `[workspace.package]` through one script shared with the release path, so the manifest, the archive filename and the tag cannot drift apart. The mcpb CLI is pinned, since its packing behaviour is load-bearing here. No crate behaviour changes. The version moves because the released artifacts do. --- .changepacks/changepack_log_mcpb_bundle.json | 10 ++ .github/workflows/ci.yml | 147 ++++++++++++++++++- README.md | 12 ++ packaging/mcpb/manifest.json | 91 ++++++++++++ packaging/mcpb/workspace-version.sh | 24 +++ 5 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_mcpb_bundle.json create mode 100644 packaging/mcpb/manifest.json create mode 100644 packaging/mcpb/workspace-version.sh diff --git a/.changepacks/changepack_log_mcpb_bundle.json b/.changepacks/changepack_log_mcpb_bundle.json new file mode 100644 index 00000000..996e4d9d --- /dev/null +++ b/.changepacks/changepack_log_mcpb_bundle.json @@ -0,0 +1,10 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Patch", + "crates/devup-mcp-figma/Cargo.toml": "Patch", + "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", + "crates/devup-mcp-visual/Cargo.toml": "Patch" + }, + "note": "Ship one MCP Bundle per release so devup-mcp installs in a click on any operating system. The previous release attached six loose binaries, which left a user to work out which of them matches their machine and then wire stdio up by hand; there was no .mcpb at all. A new bundle job now collects the same three server binaries the build matrix already produces onto one runner and packs them into a single devup-mcp-.mcpb, whose manifest selects the matching command per host through server.mcp_config.platform_overrides keyed by darwin, win32 and linux, and whose one required user_config value is the workspace directory that becomes the server's only writable root. finalize now waits on the bundle as well as the binaries, so a published release can never be missing it. Three checks hold the bundle to what it claims: the manifest is schema-validated on every pull request rather than first on a release run, when the tag and the draft would already exist; every command the manifest is able to select is confirmed to exist inside the staged archive, because mcpb validate only ever inspects server.entry_point and would pass a bundle broken on exactly one operating system; and the packed archive is read back to confirm the Unix binaries kept their executable bit, which is why the job must run on Linux - mcpb writes permission bits into the zip only from a Unix host, and it stores them without the regular-file type bits, so zipinfo renders the type column as ? and a naive check on a leading - would have rejected every correctly packed archive instead. The version is read from [workspace.package] by one script shared with the release path so the manifest, the archive filename and the tag cannot drift apart. No crate behaviour changes; the version moves because the released artifacts do.", + "date": "2026-09-08T13:10:00+09:00" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56cb121d..94ee19d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,11 @@ name: CI # *draft* releases and reports them in `pending_releases`. # 4. `build` compiles every MCP binary for all three platforms and uploads # them onto those drafts. -# 5. `finalize` publishes the drafts, but only once the uploads succeeded — -# so a release is never visible without its binaries attached. +# 5. `bundle` collects those same three binaries back into one `.mcpb` +# MCP Bundle, so a user on any operating system installs the one file +# rather than picking a binary and wiring up stdio by hand. +# 6. `finalize` publishes the drafts, but only once the uploads succeeded — +# so a release is never visible without its binaries and its bundle. on: push: @@ -29,6 +32,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +env: + # Pinned so a release cannot start producing a differently shaped bundle on + # its own. `pack` writes the archive's Unix permission bits from the mode it + # finds on disk in this version, which is why `bundle` chmods first. + MCPB_CLI_VERSION: "2.1.2" + jobs: # The action comments the changepack status on a pull request but does not # fail it. A crate change that ships without a changepack never moves the @@ -105,6 +114,20 @@ jobs: - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo insta test --workspace --all-features --check - run: cargo build --workspace --release + # The bundle is only assembled on a release run, so without this the + # first time anyone learns the manifest is malformed is after the tag + # and the draft release already exist. Schema-check it on every pull + # request instead, against the same rendering step `bundle` performs. + - name: Validate the MCPB manifest + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + set -euo pipefail + version="$(bash packaging/mcpb/workspace-version.sh)" + mkdir -p build + jq --arg version "$version" '.version = $version' \ + packaging/mcpb/manifest.json > build/manifest.json + npx --yes "@anthropic-ai/mcpb@$MCPB_CLI_VERSION" validate build/manifest.json changepacks: name: changepacks @@ -187,6 +210,17 @@ jobs: fi done ls -l dist + # `bundle` needs all three platforms' server binaries in one place, and + # each is produced on a different runner. Only the MCP server itself is + # carried: devup-mcp-visual is a comparison CLI a consumer repository + # runs in its own CI, not something an MCP host ever launches. + - name: Hand the server binary to the bundle job + uses: actions/upload-artifact@v7 + with: + name: mcpb-binary-${{ matrix.suffix }} + path: dist/devup-mcp-${{ matrix.suffix }}${{ matrix.ext }} + if-no-files-found: error + retention-days: 1 - name: Upload binaries onto the draft release shell: bash env: @@ -216,9 +250,116 @@ jobs: "${upload}?name=${name}" >/dev/null done + # Six loose binaries make a user pick the right file for their machine and + # then wire stdio up by hand. One `.mcpb` — a zip carrying manifest.json and + # all three platform binaries — installs in a click on any of them, because + # the manifest's platform_overrides selects the matching command at launch. + bundle: + name: bundle mcpb + needs: [changepacks, build] + if: >- + needs.changepacks.outputs.pending_releases != '' + && needs.changepacks.outputs.pending_releases != '{}' + # Must not be windows-latest. mcpb writes the archive's Unix permission + # bits only when packing from a Unix host; from Windows it writes none at + # all, and macOS and Linux then fail to launch the binary with EACCES. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: actions/download-artifact@v8 + with: + pattern: mcpb-binary-* + path: artifacts + merge-multiple: true + - name: Assemble and pack the bundle + shell: bash + run: | + set -euo pipefail + version="$(bash packaging/mcpb/workspace-version.sh)" + echo "bundling devup-mcp $version" + + mkdir -p build/server/linux build/server/macos build/server/win + cp artifacts/devup-mcp-linux-x86_64 build/server/linux/devup-mcp + cp artifacts/devup-mcp-macos-universal build/server/macos/devup-mcp + cp artifacts/devup-mcp-windows-x86_64.exe build/server/win/devup-mcp.exe + # The artifact round trip does not carry file modes, and the pinned + # mcpb copies the mode it finds rather than forcing +x on the entry + # point, so the executable bit has to be put back here. + chmod +x build/server/linux/devup-mcp build/server/macos/devup-mcp + + jq --arg version "$version" '.version = $version' \ + packaging/mcpb/manifest.json > build/manifest.json + + # `mcpb validate` only ever looks at server.entry_point, so a typo in + # a platform_overrides path would ship a bundle that is broken on + # exactly one operating system and healthy on the two we can see from + # here. Check every command the manifest is able to select. + missing=0 + while read -r referenced; do + [ -n "$referenced" ] || continue + if [ ! -f "build/$referenced" ]; then + echo "manifest.json names a file the bundle does not carry: $referenced" >&2 + missing=1 + fi + done < <(jq -r ' + [ .server.entry_point, + .server.mcp_config.command, + (.server.mcp_config.platform_overrides // {} | .[] | .command // empty) ] + | .[] | ltrimstr("${__dirname}/")' build/manifest.json | sort -u) + [ "$missing" -eq 0 ] + + npx --yes "@anthropic-ai/mcpb@$MCPB_CLI_VERSION" pack build "devup-mcp-$version.mcpb" + echo "MCPB_FILE=devup-mcp-$version.mcpb" >> "$GITHUB_ENV" + # A bundle whose binaries lost their executable bit installs cleanly and + # then fails at first launch, which is exactly the kind of defect a + # release should not be able to carry. Read it back out of the archive. + - name: Verify the archive kept the executable bit + shell: bash + run: | + set -euo pipefail + for entry in server/linux/devup-mcp server/macos/devup-mcp; do + mode="$(unzip -Z "$MCPB_FILE" "$entry" \ + | awk -v entry="$entry" '$NF == entry { print $1 }')" + echo "$entry: ${mode:-}" + # mcpb stores the permission bits alone, without the regular-file + # type bits, so zipinfo prints the type column as "?" rather than + # "-" even for a correctly packed archive. Only the owner execute + # position is meaningful, and an absent entry yields an empty + # string, which fails this the same way a non-executable one does. + if [ "${mode:3:1}" != "x" ]; then + echo "$entry is not executable inside $MCPB_FILE" >&2 + exit 1 + fi + done + - name: Upload the bundle onto the draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }} + run: | + set -euo pipefail + upload="$(printf '%s' "$ASSET_URLS" \ + | jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')" + if [ -z "$upload" ]; then + echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2 + printf '%s\n' "$ASSET_URLS" >&2 + exit 1 + fi + # Drop the RFC 6570 template suffix, e.g. "{?name,label}". + upload="${upload%%\{*}" + echo "uploading $MCPB_FILE" + curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$MCPB_FILE" \ + "${upload}?name=${MCPB_FILE}" >/dev/null + finalize: name: finalize release - needs: [changepacks, build] + needs: [changepacks, build, bundle] if: >- needs.changepacks.outputs.pending_releases != '' && needs.changepacks.outputs.pending_releases != '{}' diff --git a/README.md b/README.md index a9d154b0..a59a6acb 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ devup-mcp는 Figma Remote MCP에 직접 붙습니다 — OAuth discovery, Dynami ## 빌드와 설치 +### MCP Bundle (`.mcpb`) — 툴체인 없이 한 번에 설치 + +릴리스마다 `devup-mcp-.mcpb` 파일 하나가 함께 올라갑니다. 이 하나에 Linux x86_64, Windows x86_64, macOS universal 바이너리가 **모두** 들어 있고, `manifest.json`의 `server.mcp_config.platform_overrides`가 실행 시점에 호스트의 운영체제에 맞는 바이너리를 고릅니다. 운영체제별로 어떤 파일을 받아야 하는지 고를 필요가 없고, Rust 툴체인도 Node 런타임도 `cargo install`도 필요하지 않습니다. + +1. [Releases](https://github.com/dev-five-git/devup-mcp/releases)에서 `devup-mcp-.mcpb`를 받습니다. +2. `.mcpb`를 지원하는 호스트(예: Claude for macOS/Windows)에서 파일을 엽니다. +3. 설치 대화상자의 **Workspace directory**에 코드를 생성할 프로젝트 디렉터리를 지정합니다. 이 디렉터리가 devup-mcp가 파일을 쓸 수 있는 **유일한** 위치이며, `..`·다른 drive·symlink로 그 밖을 가리키는 경로는 기록 전에 거절됩니다. 여러 root가 필요하면 아래 stdio 설정으로 `--allow-write-root`를 반복해 등록하세요. + +`.mcpb`는 그냥 zip이므로 `unzip -l`로 내용을 확인할 수 있고, 안의 바이너리는 같은 릴리스에 따로 올라가는 것과 같은 파일입니다. CI는 pack 직후 아카이브를 다시 읽어 Unix 바이너리에 실행 비트가 남아 있는지 확인하고, 없으면 릴리스를 게시하지 않습니다. 그럼에도 macOS에서 설치 직후 서버가 `EACCES`로 실패한다면 호스트가 압축을 풀면서 권한을 지운 경우이며([mcpb#294](https://github.com/modelcontextprotocol/mcpb/issues/294)), 그때는 같은 릴리스의 `devup-mcp-macos-universal` 바이너리를 직접 받아 아래 stdio 설정으로 등록하면 됩니다. + +### 소스에서 빌드 + Rust 1.98 이상이 필요합니다. compile-in Figma 탐색 행동 fixture를 직접 실행하려면 CI와 동일한 Node.js 24가 필요하며 제품 binary에는 Node가 필요하지 않습니다. ```bash diff --git a/packaging/mcpb/manifest.json b/packaging/mcpb/manifest.json new file mode 100644 index 00000000..deb7fe19 --- /dev/null +++ b/packaging/mcpb/manifest.json @@ -0,0 +1,91 @@ +{ + "manifest_version": "0.2", + "name": "devup-mcp", + "display_name": "Devup MCP", + "version": "0.0.0", + "description": "Read Figma designs and generate DevupUI TSX, devup.json theme tokens and exported assets", + "long_description": "devup-mcp is a Rust-native stdio MCP server and a read-only client of Figma Remote MCP. It converts a Figma node link into deterministic @devup-ui/react TSX, projects Figma variables and local styles into devup.json, exports the assets a screen actually paints, and reports how much of the source it reproduced exactly. Connecting to Figma is a browser OAuth login run once from the devup_figma_auth tool; no Figma personal access token is involved and credentials live only in the operating system credential store. This bundle carries a self-contained binary for Linux, Windows and macOS (universal), so no Rust toolchain, Node runtime or cargo install is needed.", + "author": { + "name": "dev-five-git", + "url": "https://github.com/dev-five-git" + }, + "repository": { + "type": "git", + "url": "https://github.com/dev-five-git/devup-mcp" + }, + "homepage": "https://github.com/dev-five-git/devup-mcp", + "documentation": "https://github.com/dev-five-git/devup-mcp#readme", + "support": "https://github.com/dev-five-git/devup-mcp/issues", + "license": "MIT", + "keywords": ["figma", "devup-ui", "design-to-code", "react", "tsx", "design-tokens"], + "server": { + "type": "binary", + "entry_point": "server/linux/devup-mcp", + "mcp_config": { + "command": "${__dirname}/server/linux/devup-mcp", + "args": ["--allow-write-root", "${user_config.workspace_directory}"], + "env": {}, + "platform_overrides": { + "linux": { + "command": "${__dirname}/server/linux/devup-mcp" + }, + "darwin": { + "command": "${__dirname}/server/macos/devup-mcp" + }, + "win32": { + "command": "${__dirname}/server/win/devup-mcp.exe" + } + } + } + }, + "user_config": { + "workspace_directory": { + "type": "directory", + "title": "Workspace directory", + "description": "The project directory devup-mcp may write generated TSX, devup.json and exported assets into. It is the only writable location: a path outside it is refused before anything is written, including one reached through .., another drive or a symlink. Pick the repository you are generating code for.", + "required": true + } + }, + "tools": [ + { + "name": "devup_figma_auth", + "description": "Check, start or clear the Figma connection, or diagnose why it is not connecting" + }, + { + "name": "devup_figma_export", + "description": "Acquire a Figma design once and project TSX, component TSX, responsive TSX, devup.json, source map, raw snapshot and assets from it" + }, + { + "name": "devup_figma_to_ui", + "description": "Convert a Figma node link to deterministic DevupUI TSX" + }, + { + "name": "devup_figma_to_json", + "description": "Convert Figma variables and local styles to a devup.json theme" + }, + { + "name": "devup_figma_search", + "description": "Find pages, sections, frames and components in a Figma file by name" + }, + { + "name": "devup_figma_explore", + "description": "List the screen candidates spatially related to a linked Figma node" + }, + { + "name": "devup_project_context", + "description": "Read a project's real devup.json tokens, openapi.json endpoints or Vespertide models" + }, + { + "name": "devup_ui_validate", + "description": "Validate DevupUI TSX against a project's real devup.json tokens and component props" + }, + { + "name": "devup_stack_diff", + "description": "Detect drift across the devup stack from database model to generated API client" + } + ], + "tools_generated": false, + "compatibility": { + "platforms": ["darwin", "win32", "linux"] + } +} diff --git a/packaging/mcpb/workspace-version.sh b/packaging/mcpb/workspace-version.sh new file mode 100644 index 00000000..58543493 --- /dev/null +++ b/packaging/mcpb/workspace-version.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Prints the version `changepacks update` maintains in [workspace.package]. +# +# Every crate sets `version.workspace = true`, so this one number is the +# released version and the version the MCP bundle must carry. Reading it here +# rather than in each workflow step keeps the manifest, the archive filename +# and the release tag from drifting apart. +# +# The section range matters: `version` also appears in [workspace.dependencies] +# entries, and reading the whole file would eventually pick one of those up. +# sed looks for the range's closing address on the line after the opening one, +# so `[workspace.package]` does not close its own range. +set -euo pipefail + +manifest="${1:-Cargo.toml}" + +version="$(sed -n '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"\(.*\)"/\1/p' "$manifest" | head -n1)" + +if [ -z "$version" ]; then + echo "no [workspace.package] version found in $manifest" >&2 + exit 1 +fi + +printf '%s\n' "$version"