diff --git a/.changepacks/changepack_log_mcpb_bundle.json b/.changepacks/changepack_log_mcpb_bundle.json new file mode 100644 index 0000000..996e4d9 --- /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 56cb121..94ee19d 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 a9d154b..a59a6ac 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 0000000..deb7fe1 --- /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 0000000..5854349 --- /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"