Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changepacks/changepack_log_mcpb_bundle.json
Original file line number Diff line number Diff line change
@@ -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-<version>.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"
}
147 changes: 144 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:-<absent>}"
# 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 != '{}'
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ devup-mcp는 Figma Remote MCP에 직접 붙습니다 — OAuth discovery, Dynami

## 빌드와 설치

### MCP Bundle (`.mcpb`) — 툴체인 없이 한 번에 설치

릴리스마다 `devup-mcp-<version>.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-<version>.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
Expand Down
91 changes: 91 additions & 0 deletions packaging/mcpb/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
24 changes: 24 additions & 0 deletions packaging/mcpb/workspace-version.sh
Original file line number Diff line number Diff line change
@@ -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"