From 311dafb55bfb408ec136843aa034e312e5bd85c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kutryj?= Date: Fri, 19 Jun 2026 11:47:41 +0200 Subject: [PATCH] feat(mcpb): package sem-ai as an MCPB desktop extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle the `sem-ai mcp` stdio server as a one-click MCPB (.mcpb) extension for Claude Desktop and other MCPB-aware apps. - config: SEMAPHORE_HOST / SEMAPHORE_API_TOKEN env vars override ~/.sem.yaml, so the install dialog can inject credentials (token kept in the OS keychain) and the same vars configure the CLI in CI. - mcpb/manifest.json: binary server type, user_config (host + sensitive token) mapped to env, win32 .exe platform override. - packaging is folded into GoReleaser: a build post-hook runs the official `mcpb pack` (scripts/mcpb-pack.sh) on each freshly built binary, and release.extra_files attaches dist/*.mcpb to the release — one bundle per OS+arch (platform_overrides keys on OS, not arch). The post-hook skips snapshot builds (the -next suffix), so PR/snapshot pipelines stay node-free; only the release job installs mcpb and packs. - `make mcpb` builds a local bundle for the current platform into dist/. - CI: PR pipeline validates mcpb/manifest.json via `mcpb validate`. - version-synced as a 4th manifest via release.sh + check-manifest-versions.sh. - install.sh points Claude Desktop users to the bundle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + .goreleaser.yaml | 7 +++ .semaphore/release.yml | 2 + .semaphore/semaphore.yml | 5 ++ Makefile | 10 +++- install.sh | 5 ++ mcpb/README.md | 43 +++++++++++++++ mcpb/manifest.json | 57 +++++++++++++++++++ pkg/config/config.go | 24 ++++++-- pkg/config/config_test.go | 88 ++++++++++++++++++++++++++++++ scripts/check-manifest-versions.sh | 8 ++- scripts/mcpb-pack.sh | 53 ++++++++++++++++++ scripts/release.sh | 9 ++- 13 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 mcpb/README.md create mode 100644 mcpb/manifest.json create mode 100644 pkg/config/config_test.go create mode 100755 scripts/mcpb-pack.sh diff --git a/.gitignore b/.gitignore index fa64cc9..31637c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ sem-ai *.exe +dist/ +*.mcpb .planning/ .DS_Store .env diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 74e18e7..1e6f6c1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,6 +20,9 @@ builds: goarch: arm64 ldflags: - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + hooks: + post: + - 'bash scripts/mcpb-pack.sh "{{ .Path }}" "{{ .Os }}" "{{ .Arch }}" "{{ .Version }}"' archives: - id: default @@ -31,6 +34,10 @@ archives: formats: - zip +release: + extra_files: + - glob: ./dist/*.mcpb + checksum: name_template: "checksums.txt" diff --git a/.semaphore/release.yml b/.semaphore/release.yml index 38a7386..fec78b6 100644 --- a/.semaphore/release.yml +++ b/.semaphore/release.yml @@ -34,6 +34,8 @@ blocks: - cache restore go-mod-$(checksum go.sum),go-mod-main - cache restore go-build-$SEMAPHORE_GIT_BRANCH,go-build-main - export GITHUB_TOKEN="$GITHUB_TOKEN_PAT" + - sem-version node 20 + - npm install -g @anthropic-ai/mcpb - curl -sfL https://goreleaser.com/static/run -o /tmp/goreleaser-run - chmod +x /tmp/goreleaser-run - /tmp/goreleaser-run release --clean --skip=before diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index e471446..7f06781 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -31,6 +31,11 @@ blocks: commands: - make check-versions + - name: MCPB manifest + commands: + - sem-version node 20 + - npx -y @anthropic-ai/mcpb validate mcpb/manifest.json + - name: Lint commands: - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.12.2 diff --git a/Makefile b/Makefile index 890a616..bd0fe29 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) INSTALL := /usr/local/bin -.PHONY: build install uninstall clean test fmt vet release tag check-versions +.PHONY: build install uninstall clean test fmt vet mcpb release tag check-versions build: go build -ldflags "$(LDFLAGS)" -o $(BINARY) . @@ -30,7 +30,13 @@ fmt: vet: go vet ./... -# Check plugin manifests are consistent (all three carry the same version). +# Build a local MCPB (.mcpb) bundle for the current platform into dist/. +# Requires node — uses the official `mcpb` packer (npx fetches it if absent). +# make mcpb +mcpb: build + @./scripts/mcpb-pack.sh "$(BINARY)" "$$(go env GOOS)" "$$(go env GOARCH)" dev + +# Check plugin manifests are consistent (all four carry the same version). # make check-versions # files match each other # make check-versions TAG=0.1.8 # files also match the given tag check-versions: diff --git a/install.sh b/install.sh index 809746c..546d8ed 100755 --- a/install.sh +++ b/install.sh @@ -120,3 +120,8 @@ say " /plugin marketplace update semaphoreio # already installed — re say " /reload-plugins # apply changes without restart" say "" say "Then run /sem-ai:init in a repo to set up Semaphore CI/CD for it." +say "" +say "Prefer Claude Desktop? Install the one-click MCPB extension instead of the CLI —" +say "download sem-ai_${ver}_${os}_${arch}.mcpb from the release and open it:" +say " ${DL_BASE}/${tag}/sem-ai_${ver}_${os}_${arch}.mcpb" +say "It bundles this same server; enter your org host + API token in the install dialog." diff --git a/mcpb/README.md b/mcpb/README.md new file mode 100644 index 0000000..9039d72 --- /dev/null +++ b/mcpb/README.md @@ -0,0 +1,43 @@ +# MCPB desktop extension + +[MCPB](https://github.com/anthropics/mcpb) (MCP Bundle) packages the `sem-ai` +MCP server as a one-click desktop extension for Claude Desktop and other +MCPB-aware apps. The user installs a `.mcpb`, enters their organization host and +API token in the install dialog (token stored in the OS keychain), and the app +launches `sem-ai mcp` for them — no terminal, no `~/.sem.yaml` editing. + +## How config flows + +The bundle injects the user's input as environment variables: + +| Manifest `user_config` | Env var | Read by | +| ---------------------- | --------------------- | ---------------------- | +| `host` | `SEMAPHORE_HOST` | `config.Load()` | +| `api_token` | `SEMAPHORE_API_TOKEN` | `config.Load()` | + +Env wins over `~/.sem.yaml`, so the same env vars also configure the plain CLI +in CI or scripted contexts. + +## Packaging + +One bundle ships per OS+arch — MCPB `platform_overrides` keys on OS only, not +CPU architecture. Packing is folded into the GoReleaser run: a build post-hook +calls `scripts/mcpb-pack.sh` for each freshly built binary, which stages +`manifest.json` + `server/` and runs the official `mcpb pack`. The +resulting `dist/*.mcpb` files are attached to the GitHub release via +`release.extra_files` in `.goreleaser.yaml` — no separate step, no re-download +of published assets. The release job installs `mcpb` (`npm i -g +@anthropic-ai/mcpb`) before invoking GoReleaser. + +`mcpb/manifest.json` is version-synced with the plugin manifests via +`scripts/release.sh` and guarded by `scripts/check-manifest-versions.sh`. + +## Local dev + +```sh +make mcpb # build + pack into dist/ (needs node) +npx @anthropic-ai/mcpb info dist/sem-ai_dev_*.mcpb +``` + +Open the resulting `dist/sem-ai_dev_*.mcpb` with Claude Desktop to test the +install dialog. diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..9124d67 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": "0.3", + "name": "sem-ai", + "display_name": "Semaphore CI", + "version": "0.1.24", + "description": "Agent-first Semaphore CI/CD — manage pipelines, tests, deploys, flaky detection.", + "long_description": "sem-ai exposes the full Semaphore CI/CD loop as MCP tools: inspect pipelines and workflows, read job logs, surface flaky tests and insights, manage secrets and notifications, trigger deploys, and validate YAML. Connect with your organization host and a Semaphore API token.", + "author": { + "name": "Semaphore CI", + "url": "https://semaphoreci.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/semaphoreio/sem-ai.git" + }, + "homepage": "https://semaphoreci.com", + "documentation": "https://github.com/semaphoreio/sem-ai", + "support": "https://github.com/semaphoreio/sem-ai/issues", + "license": "Apache-2.0", + "keywords": ["semaphore", "ci", "cd", "pipelines", "devops"], + "server": { + "type": "binary", + "entry_point": "server/sem-ai", + "mcp_config": { + "command": "${__dirname}/server/sem-ai", + "args": ["mcp"], + "env": { + "SEMAPHORE_HOST": "${user_config.host}", + "SEMAPHORE_API_TOKEN": "${user_config.api_token}" + }, + "platform_overrides": { + "win32": { + "command": "${__dirname}/server/sem-ai.exe" + } + } + } + }, + "user_config": { + "host": { + "type": "string", + "title": "Organization Host", + "description": "Your Semaphore organization host, e.g. myorg.semaphoreci.com", + "required": true + }, + "api_token": { + "type": "string", + "title": "API Token", + "description": "Semaphore API token (Account Settings → API Tokens)", + "sensitive": true, + "required": true + } + }, + "tools_generated": true, + "compatibility": { + "platforms": ["darwin", "win32", "linux"] + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4822610..8bad780 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,11 +2,17 @@ package config import ( "fmt" + "os" "sort" "github.com/spf13/viper" ) +const ( + EnvToken = "SEMAPHORE_API_TOKEN" + EnvHost = "SEMAPHORE_HOST" +) + var cfg *Config type Context struct { @@ -16,8 +22,8 @@ type Context struct { type Config struct { ActiveContext string - Token string - Host string + Token string + Host string } func Load() { @@ -27,11 +33,21 @@ func Load() { cfg.Token = viper.GetString(fmt.Sprintf("contexts.%s.auth.token", cfg.ActiveContext)) cfg.Host = viper.GetString(fmt.Sprintf("contexts.%s.host", cfg.ActiveContext)) } + + if t := os.Getenv(EnvToken); t != "" { + cfg.Token = t + } + if h := os.Getenv(EnvHost); h != "" { + cfg.Host = h + if cfg.ActiveContext == "" { + cfg.ActiveContext = "env" + } + } } func GetActiveContext() string { return cfg.ActiveContext } -func GetToken() string { return cfg.Token } -func GetHost() string { return cfg.Host } +func GetToken() string { return cfg.Token } +func GetHost() string { return cfg.Host } func ContextList() ([]Context, error) { raw := viper.GetStringMap("contexts") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..a0ff741 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,88 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" +) + +func setFileContext(name, token, host string) { + viper.Set("active-context", name) + viper.Set("contexts."+name+".auth.token", token) + viper.Set("contexts."+name+".host", host) +} + +func TestLoad_FileContext(t *testing.T) { + viper.Reset() + t.Setenv(EnvToken, "") + t.Setenv(EnvHost, "") + setFileContext("acme", "filetok", "acme.semaphoreci.com") + + Load() + + if GetToken() != "filetok" { + t.Errorf("token = %q, want %q", GetToken(), "filetok") + } + if GetHost() != "acme.semaphoreci.com" { + t.Errorf("host = %q, want %q", GetHost(), "acme.semaphoreci.com") + } + if !IsConfigured() { + t.Error("IsConfigured() = false, want true") + } +} + +func TestLoad_EnvOnly(t *testing.T) { + viper.Reset() + t.Setenv(EnvToken, "envtok") + t.Setenv(EnvHost, "env.semaphoreci.com") + + Load() + + if GetToken() != "envtok" { + t.Errorf("token = %q, want %q", GetToken(), "envtok") + } + if GetHost() != "env.semaphoreci.com" { + t.Errorf("host = %q, want %q", GetHost(), "env.semaphoreci.com") + } + if GetActiveContext() != "env" { + t.Errorf("active context = %q, want %q", GetActiveContext(), "env") + } + if !IsConfigured() { + t.Error("IsConfigured() = false, want true") + } +} + +func TestLoad_EnvOverridesFile(t *testing.T) { + viper.Reset() + setFileContext("acme", "filetok", "acme.semaphoreci.com") + t.Setenv(EnvToken, "envtok") + t.Setenv(EnvHost, "env.semaphoreci.com") + + Load() + + if GetToken() != "envtok" { + t.Errorf("token = %q, want env to win, got %q", GetToken(), "envtok") + } + if GetHost() != "env.semaphoreci.com" { + t.Errorf("host = %q, want env to win, got %q", GetHost(), "env.semaphoreci.com") + } + if GetActiveContext() != "acme" { + t.Errorf("active context = %q, want file context preserved", GetActiveContext()) + } +} + +func TestLoad_EmptyEnvFallsBackToFile(t *testing.T) { + viper.Reset() + setFileContext("acme", "filetok", "acme.semaphoreci.com") + t.Setenv(EnvToken, "") + t.Setenv(EnvHost, "") + + Load() + + if GetToken() != "filetok" { + t.Errorf("token = %q, want file value when env blank", GetToken()) + } + if GetHost() != "acme.semaphoreci.com" { + t.Errorf("host = %q, want file value when env blank", GetHost()) + } +} diff --git a/scripts/check-manifest-versions.sh b/scripts/check-manifest-versions.sh index 650add8..99a1078 100755 --- a/scripts/check-manifest-versions.sh +++ b/scripts/check-manifest-versions.sh @@ -10,6 +10,7 @@ # .claude-plugin/marketplace.json (.plugins[0].version) # assets/plugin/plugin.json (.version) # assets/plugin/.codex-plugin/plugin.json (.version) +# mcpb/manifest.json (.version) # # Exits non-zero on any mismatch with a clear diagnostic. @@ -18,8 +19,9 @@ set -euo pipefail MARKETPLACE=".claude-plugin/marketplace.json" PLUGIN="assets/plugin/plugin.json" CODEX="assets/plugin/.codex-plugin/plugin.json" +MCPB="mcpb/manifest.json" -for f in "$MARKETPLACE" "$PLUGIN" "$CODEX"; do +for f in "$MARKETPLACE" "$PLUGIN" "$CODEX" "$MCPB"; do if [[ ! -f "$f" ]]; then echo "ERROR: run from repo root — missing $f" >&2 exit 2 @@ -34,12 +36,14 @@ fi market_ver=$(yq -p json -o tsv '.plugins[0].version' "$MARKETPLACE") plugin_ver=$(yq -p json -o tsv '.version' "$PLUGIN") codex_ver=$(yq -p json -o tsv '.version' "$CODEX") +mcpb_ver=$(yq -p json -o tsv '.version' "$MCPB") -if [[ "$market_ver" != "$plugin_ver" || "$market_ver" != "$codex_ver" ]]; then +if [[ "$market_ver" != "$plugin_ver" || "$market_ver" != "$codex_ver" || "$market_ver" != "$mcpb_ver" ]]; then echo "FAIL: plugin manifest versions disagree" >&2 echo " $MARKETPLACE → $market_ver" >&2 echo " $PLUGIN → $plugin_ver" >&2 echo " $CODEX → $codex_ver" >&2 + echo " $MCPB → $mcpb_ver" >&2 echo " bump all with: make release VERSION=" >&2 exit 1 fi diff --git a/scripts/mcpb-pack.sh b/scripts/mcpb-pack.sh new file mode 100755 index 0000000..60761f3 --- /dev/null +++ b/scripts/mcpb-pack.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Pack one MCPB (.mcpb) bundle from a freshly built binary, using the official +# `mcpb pack` CLI. Invoked by GoReleaser's build post-hook once per target, so +# the bundle is produced straight from dist/ — no re-download of release assets. +# +# Usage (from repo root, as GoReleaser runs it): +# scripts/mcpb-pack.sh +# +# One bundle per OS+arch: MCPB platform_overrides keys on OS only, not CPU arch. +# Output: dist/sem-ai___.mcpb (picked up by release.extra_files). +# +# Requires node (mcpb on PATH, else npx fetches it). + +set -euo pipefail + +bin_path="$1" +os="$2" +arch="$3" +ver="$4" + +# GoReleaser runs this post-hook on snapshot builds too (PR CI + snapshot +# pipelines), which don't ship bundles and have no mcpb installed. Snapshots +# carry the `-next` suffix from .goreleaser.yaml's version_template — skip them. +case "$ver" in + *-next) + echo "skipping mcpb pack for snapshot $ver" + exit 0 + ;; +esac + +root="$(cd "$(dirname "$0")/.." && pwd)" +manifest="$root/mcpb/manifest.json" + +binname="sem-ai" +[ "$os" = "windows" ] && binname="sem-ai.exe" + +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/server" +cp "$manifest" "$stage/manifest.json" +cp "$bin_path" "$stage/server/$binname" +chmod +x "$stage/server/$binname" + +mkdir -p "$root/dist" +out="$root/dist/sem-ai_${ver}_${os}_${arch}.mcpb" + +if command -v mcpb >/dev/null 2>&1; then + mcpb pack "$stage" "$out" +else + npx -y @anthropic-ai/mcpb pack "$stage" "$out" +fi + +echo "packed $(basename "$out")" diff --git a/scripts/release.sh b/scripts/release.sh index 70c5656..0210aa1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -22,6 +22,7 @@ # .claude-plugin/marketplace.json (.plugins[0].version) # assets/plugin/plugin.json (.version) # assets/plugin/.codex-plugin/plugin.json (.version) +# mcpb/manifest.json (.version) # # Does NOT push and does NOT tag — release stays gated on deliberate human steps. @@ -30,6 +31,7 @@ set -euo pipefail MARKETPLACE=".claude-plugin/marketplace.json" PLUGIN="assets/plugin/plugin.json" CODEX="assets/plugin/.codex-plugin/plugin.json" +MCPB="mcpb/manifest.json" dry_run=0 if [[ "${1:-}" == "--dry-run" ]]; then @@ -63,7 +65,7 @@ if ! command -v yq >/dev/null 2>&1; then exit 2 fi -for f in "$MARKETPLACE" "$PLUGIN" "$CODEX"; do +for f in "$MARKETPLACE" "$PLUGIN" "$CODEX" "$MCPB"; do if [[ ! -f "$f" ]]; then echo "ERROR: run from repo root — missing $f" >&2 exit 2 @@ -92,11 +94,12 @@ fi run yq -i -o json ".plugins[0].version = \"$version\"" "$MARKETPLACE" run yq -i -o json ".version = \"$version\"" "$PLUGIN" run yq -i -o json ".version = \"$version\"" "$CODEX" +run yq -i -o json ".version = \"$version\"" "$MCPB" if [[ "$dry_run" -eq 0 ]]; then - git --no-pager diff --stat "$MARKETPLACE" "$PLUGIN" "$CODEX" + git --no-pager diff --stat "$MARKETPLACE" "$PLUGIN" "$CODEX" "$MCPB" fi -run git add "$MARKETPLACE" "$PLUGIN" "$CODEX" +run git add "$MARKETPLACE" "$PLUGIN" "$CODEX" "$MCPB" run git commit -m "chore(release): bump plugin manifests to v$version" echo